REST API Reference
PhishSpot exposes a REST API over JSON 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 stream results into your own tooling.
This chapter documents every endpoint in detail — parameters, request bodies, response fields and status codes — so you can integrate without reading the source. For a push-based event model see Chapter 26 Webhooks; for a natural-language AI interface over the same capabilities see Chapter 29 MCP Server.
27.1 Authentication
Section titled “27.1 Authentication”Every authenticated request must send 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). Copy the value — it is shown once. Store it in 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"}'{ "token": "abc123…", "user": { "id": 2, "email": "admin@example.com" } }| Field | Type | Description |
|---|---|---|
email | string | Required. The user’s email. |
password | string | Required. The user’s password. |
otp_attempt | string | Required only if the user has two-factor auth enabled. |
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.
27.2 Conventions
Section titled “27.2 Conventions”- Base URL:
https://platform.phishspot.com/api/v1. All paths below are relative to it. - Content type: send
Content-Type: application/json; bodies and responses are JSON. - Times: ISO-8601 (
2026-05-20T14:22:33.000Z), UTC unless stated. The campaignscheduled_atinput is interpreted in the account’s timezone. - IDs in paths: wherever a path takes
:id, you may pass either the integer primary key (/campaigns/42) or the record’s prefixed id (/campaigns/camp_0u1k…). Responses always expose the integerid; some also expose the prefixed id. account_id: nested routes takeaccount_idin the path; it accepts the integer id or theacct_…prefixed id. Discover yours withGET /accounts.- Account scoping: a token can act only on accounts its user belongs to. Requesting a record in another account returns 404 — the API never confirms another tenant’s data exists.
- Roles: read endpoints require any role (incl.
member). Write endpoints (POST/PATCH/PUT/DELETEand state actions) require admin or editor; amembertoken gets403. Team/billing and platform-domain admin actions require admin. - Pagination: endpoints that paginate accept
?page=N(1-based) and sometimes?per_page=Mor?limit=M; defaults are noted per endpoint. Non-paginated lists return the full ordered set.
Shared error responses
Section titled “Shared error responses”Unless an endpoint says otherwise, these apply to every call (only endpoint-specific codes are repeated below):
| Code | Body | When |
|---|---|---|
401 Unauthorized | (empty) | Missing or invalid Authorization token. |
403 Forbidden | {"error":"You are not authorized to perform this action"} | Token valid but the user’s role is insufficient for this action. |
404 Not Found | {"error":"Resource not found"} | No such record, or the record belongs to an account the token can’t access. |
422 Unprocessable Content | {"errors":{"field":["message"]}} (or {"errors":["message"]} for action endpoints) | Validation failed; inspect errors. |
429 Too Many Requests | (varies) | Rate limit exceeded; see §27.17. The Retry-After header says when to retry. |
27.3 Identity & accounts
Section titled “27.3 Identity & accounts”GET /me
Section titled “GET /me”Returns the user behind the token.
Parameters: none (bearer token only).
curl -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/meResponse 200 OK
| Field | Type | Description |
|---|---|---|
id | integer | User id. |
email | string | User email. |
name | string | Display name. |
locale | string | UI locale (en / pl). |
accounts | array | Accounts the token can act on (see GET /accounts). |
GET /accounts
Section titled “GET /accounts”Lists every account the token’s user can access. Use it to find the account_id for nested routes.
Parameters: none.
curl -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/accountsResponse 200 OK — array of:
| Field | Type | Description |
|---|---|---|
id | integer | Account id (use in nested paths). |
prefix_id | string | Prefixed id (acct_…). |
name | string | Account name. |
locale | string | Account default locale. |
[{ "id": 11, "name": "Cydefen Tests", "locale": "pl", "prefix_id": "acct_3kf…" }]27.4 Campaigns
Section titled “27.4 Campaigns”Manage phishing-simulation campaigns: create and edit drafts, drive the campaign through its lifecycle (start, pause, stop, cancel), schedule a future send, duplicate, and read results, recipient progress, replies, and a per-contact event timeline.
Authorization is enforced per account: a campaign is only reachable if it belongs to one of the accounts the bearer token’s user is a member of (any membership role can read and write — there are no admin-only campaign actions). State-transition endpoints additionally require the campaign to be in a compatible state (e.g. you can only pause an in-progress campaign), returning 403 otherwise.
The campaign object emitted by show, create, update, and all state-transition endpoints is identical and described once under GET /campaigns/:id.
GET /accounts/:account_id/campaigns
Section titled “GET /accounts/:account_id/campaigns”Lists every campaign in the account, newest first. Use it to enumerate campaigns before drilling into one. Auth: Bearer; role: read (any role).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| account_id | path | string | yes | Account id (acct_… or integer). Must be an account the token’s user belongs to. |
Request
curl -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/accounts/11/campaignsResponse 200 OK — JSON array of campaign objects (see GET /campaigns/:id for the per-object field list), ordered by created_at descending.
[ { "id": 42, "account_id": 11, "name": "Q2 Invoice Lure", "state": "in_progress", "delivery_mode": "immediate", "delivery_schedule": null, "created_at": "2026-05-01T09:00:00.000Z", "updated_at": "2026-05-02T14:12:00.000Z", "email_subject": "Your April invoice is ready", "email_content": "<p>Hello {{first_name}}…</p>", "landing_html": "<form>…</form>", "domain": "officelogin.in", "course_id": 7, "groups": [{ "id": 3, "name": "Finance" }], "statistics": { "total_contacts": 120, "total_deliverables": 120, "completion_percentage": 100.0 }, "can_start": false, "can_pause": true, "can_cancel": true }]Status codes
| Code | When |
|---|---|
| 200 | Campaigns listed (empty array if the account has none). |
| 403 | Token’s user is not authorized to view the account. |
| 404 | account_id is not an account the token’s user belongs to. |
POST /accounts/:account_id/campaigns
Section titled “POST /accounts/:account_id/campaigns”Creates a new draft campaign in the account. All content fields are optional at creation — only name is required — so you can create a bare draft and fill it in with PATCH. Auth: Bearer; role: any role.
Parameters
All body params are wrapped in a campaign object.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| account_id | path | string | yes | Account id (acct_… or integer). |
| campaign[name] | body | string | yes | Campaign name. Must be unique within the account (case-insensitive). |
| campaign[delivery_mode] | body | string | no | One of immediate, scheduled, staggered. Defaults to immediate. |
| campaign[delivery_schedule] | body | string | no | Free-form schedule string used only when delivery_mode is scheduled (ISO8601 datetime, or 5-field cron). Prefer the /schedule endpoint instead. |
| campaign[email_subject] | body | string | no | Subject line. May contain email merge tags (e.g. {{first_name}}); unknown tags fail validation. |
| campaign[email_content] | body | string | no | HTML email body. Must be well-formed HTML and use only allowed email merge tags. |
| campaign[landing_html] | body | string | no | Landing-page HTML. Must be well-formed HTML and use only allowed landing merge tags. |
| campaign[landing_css] | body | string | no | Landing-page CSS. Must be well-formed CSS. |
| campaign[landing_page_enabled] | body | boolean | no | Whether the landing page is served. Defaults to false. |
| campaign[platform_domain_id] | body | integer | no | Id of the PlatformDomain (attacker domain) used for sending and landing. Required before the campaign can start. |
| campaign[course_id] | body | integer | no | Id of the e-learning course to redirect victims to (used when end_action_type is redirect_to_course). |
| campaign[from_email] | body | string | no | Sender email address. Required before the campaign can start. |
| campaign[from_name] | body | string | no | Sender display name. |
| campaign[end_action_type] | body | string | no | What happens after a victim acts. One of nothing, redirect_to_course, message_page, redirect_to_url. Defaults to message_page. |
| campaign[end_action_url] | body | string | no | External URL to redirect to. Required (and must be http/https, not loop back to a platform domain) when end_action_type is redirect_to_url. |
| campaign[end_action_html] | body | string | no | Custom HTML message page. Required when end_action_type is message_page (auto-seeded with a default if omitted). |
| campaign[group_ids] | body | array of integer | no | Ids of contact groups to target. |
Request
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "campaign": { "name": "Q2 Invoice Lure", "delivery_mode": "immediate", "email_subject": "Your April invoice is ready", "email_content": "<p>Hello {{first_name}}…</p>", "from_email": "billing@officelogin.in", "from_name": "Accounts Payable", "platform_domain_id": 5, "end_action_type": "redirect_to_course", "course_id": 7, "group_ids": [3] } }' \ https://platform.phishspot.com/api/v1/accounts/11/campaignsResponse 201 Created — the newly created campaign object (same shape as GET /campaigns/:id).
Status codes
| Code | When |
|---|---|
| 201 | Campaign created. |
| 400 | The campaign object is missing from the body (ParameterMissing). |
| 403 | Token’s user is not authorized to create campaigns in the account. |
| 404 | account_id is not an account the token’s user belongs to. |
| 422 | Validation failed — e.g. blank/duplicate name, invalid delivery_mode/end_action_type enum, malformed HTML/CSS, disallowed merge tag, or missing end_action_url/end_action_html for the chosen end_action_type. Body: { "errors": { "<field>": ["…"] } }. |
GET /campaigns/:id
Section titled “GET /campaigns/:id”Fetches a single campaign by id (shallow route, not nested under account). Use it to read full campaign content and the action flags that tell you which transitions are currently allowed. Auth: Bearer; role: any role.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Campaign id (camp_… or integer). Must belong to an account the token’s user is a member of. |
Request
curl -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/campaigns/42Response 200 OK — the campaign object.
| Field | Type | Description |
|---|---|---|
| id | integer | Campaign id. |
| account_id | integer | Owning account id. |
| name | string | Campaign name. |
| state | string | Lifecycle state: draft, in_progress, paused, cancelled, done, or scheduled. |
| delivery_mode | string | immediate, scheduled, or staggered. |
| delivery_schedule | string | null | Raw delivery-schedule string (only meaningful for scheduled mode). |
| created_at | string | ISO8601 timestamp. |
| updated_at | string | ISO8601 timestamp. |
| email_subject | string | null | Email subject. |
| email_content | string | null | HTML email body. |
| landing_html | string | null | Landing-page HTML. |
| domain | string | null | Name of the associated PlatformDomain (e.g. officelogin.in), or null if none set. |
| course_id | integer | null | Associated course id, or null. |
| groups | array | Targeted groups, each { "id": integer, "name": string }. |
| statistics | object | Present only when state is in_progress, paused, or done. Object with total_contacts (integer), total_deliverables (integer), completion_percentage (float). |
| can_start | boolean | Whether start/schedule is allowed now (true for draft/scheduled). |
| can_pause | boolean | Whether pause is allowed now (true only when in_progress). |
| can_cancel | boolean | Whether cancel is allowed now (true for in_progress/paused/scheduled). |
{ "id": 42, "account_id": 11, "name": "Q2 Invoice Lure", "state": "draft", "delivery_mode": "immediate", "delivery_schedule": null, "created_at": "2026-05-01T09:00:00.000Z", "updated_at": "2026-05-01T09:00:00.000Z", "email_subject": "Your April invoice is ready", "email_content": "<p>Hello {{first_name}}…</p>", "landing_html": "<form>…</form>", "domain": "officelogin.in", "course_id": 7, "groups": [{ "id": 3, "name": "Finance" }], "can_start": true, "can_pause": false, "can_cancel": false}Status codes
| Code | When |
|---|---|
| 200 | Campaign returned. |
| 404 | No campaign with that id in any account the token’s user belongs to (includes cross-account access attempts). |
PATCH /campaigns/:id
Section titled “PATCH /campaigns/:id”Updates an existing campaign. Editing is only permitted while the campaign is in draft or scheduled state (the authorization policy rejects edits to running/finished campaigns). Auth: Bearer; role: any role.
Parameters
Body params are wrapped in a campaign object; the same permitted keys as POST apply (all optional on update — send only the fields you want to change).
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Campaign id (camp_… or integer). |
| campaign[…] | body | — | no | Any subset of the keys listed under POST /accounts/:account_id/campaigns (name, delivery_mode, delivery_schedule, email_subject, email_content, landing_html, landing_css, landing_page_enabled, platform_domain_id, course_id, from_email, from_name, end_action_type, end_action_url, end_action_html, group_ids[]). |
Request
curl -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "campaign": { "email_subject": "Action required: invoice overdue" } }' \ https://platform.phishspot.com/api/v1/campaigns/42Response 200 OK — the updated campaign object (same shape as GET /campaigns/:id).
Status codes
| Code | When |
|---|---|
| 200 | Campaign updated. |
| 400 | The campaign object is missing from the body (ParameterMissing). |
| 403 | Campaign is not in draft/scheduled state (editing locked), or user not authorized. |
| 404 | Campaign not found in the user’s accounts. |
| 422 | Validation failed (same validations as POST). Body: { "errors": { … } }. |
DELETE /campaigns/:id
Section titled “DELETE /campaigns/:id”Permanently deletes a campaign and its dependent records (recipients, deliverables, events, replies). Allowed in any state. Auth: Bearer; role: any role.
No parameters beyond the bearer token and the path id.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Campaign id (camp_… or integer). |
Request
curl -X DELETE -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/campaigns/42Response 204 No Content — empty body.
Status codes
| Code | When |
|---|---|
| 204 | Campaign deleted. |
| 403 | User not authorized to delete the campaign. |
| 404 | Campaign not found in the user’s accounts. |
POST /campaigns/:id/start
Section titled “POST /campaigns/:id/start”Transitions a draft or scheduled campaign to in_progress and enqueues the send jobs. Before sending, the server runs a readiness preflight and rejects the request if the campaign is incomplete. Auth: Bearer; role: any role.
No parameters beyond the bearer token and the path id.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Campaign id (camp_… or integer). |
Request
curl -X POST -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/campaigns/42/startResponse 200 OK — the campaign object with state: "in_progress".
Status codes
| Code | When |
|---|---|
| 200 | Campaign started; sends enqueued. |
| 403 | Campaign is not in a startable state (draft/paused/scheduled). |
| 404 | Campaign not found in the user’s accounts. |
| 422 | Readiness preflight failed. Body: { "errors": ["…", "…"] } (a flat array of human-readable messages). Triggers include: missing email subject, missing email content, missing sender email, no platform domain set, platform domain not active or sending-blocked, no recipients targeted, and end-action gaps (missing course for redirect_to_course, missing URL for redirect_to_url, missing HTML for message_page). |
POST /campaigns/:id/stop
Section titled “POST /campaigns/:id/stop”Marks an in-progress campaign as done (completed). Use it to end a running campaign early; pending sends are not re-enqueued. Auth: Bearer; role: any role.
No parameters beyond the bearer token and the path id.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Campaign id (camp_… or integer). |
Request
curl -X POST -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/campaigns/42/stopResponse 200 OK — the campaign object with state: "done".
Status codes
| Code | When |
|---|---|
| 200 | Campaign marked done. |
| 403 | Campaign is not in_progress. |
| 404 | Campaign not found in the user’s accounts. |
| 422 | State transition rejected by the model. Body: { "errors": { … } }. |
POST /campaigns/:id/pause
Section titled “POST /campaigns/:id/pause”Pauses an in-progress campaign (state → paused), halting further scheduled sends. Resume by calling start again. Auth: Bearer; role: any role.
No parameters beyond the bearer token and the path id.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Campaign id (camp_… or integer). |
Request
curl -X POST -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/campaigns/42/pauseResponse 200 OK — the campaign object with state: "paused".
Status codes
| Code | When |
|---|---|
| 200 | Campaign paused. |
| 403 | Campaign is not in_progress. |
| 404 | Campaign not found in the user’s accounts. |
| 422 | State transition rejected by the model. Body: { "errors": { … } }. |
POST /campaigns/:id/cancel
Section titled “POST /campaigns/:id/cancel”Cancels a campaign (state → cancelled). Allowed from in_progress, paused, or scheduled (not from draft or already-finished campaigns). Auth: Bearer; role: any role.
No parameters beyond the bearer token and the path id.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Campaign id (camp_… or integer). |
Request
curl -X POST -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/campaigns/42/cancelResponse 200 OK — the campaign object with state: "cancelled".
Status codes
| Code | When |
|---|---|
| 200 | Campaign cancelled. |
| 403 | Campaign is not in a cancellable state (in_progress/paused/scheduled). |
| 404 | Campaign not found in the user’s accounts. |
| 422 | State transition rejected by the model. Body: { "errors": { … } }. |
POST /campaigns/:id/duplicate
Section titled “POST /campaigns/:id/duplicate”Clones the campaign into a fresh draft (with a numbered-suffix name like "Q2 Invoice Lure (1)"), copying content, targeted groups, and recipients but resetting state, schedule, and snapshot. Use it to re-run or branch a campaign. Auth: Bearer; role: any role.
No parameters beyond the bearer token and the path id.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Campaign id (camp_… or integer) of the source campaign. |
Request
curl -X POST -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/campaigns/42/duplicateResponse 201 Created — the new draft campaign object (same shape as GET /campaigns/:id), with a new id and state: "draft".
Status codes
| Code | When |
|---|---|
| 201 | Duplicate created. |
| 403 | User not authorized. |
| 404 | Source campaign not found in the user’s accounts. |
| 422 | The duplicate failed to save (validation). Body: { "errors": { … } }. |
POST /campaigns/:id/schedule
Section titled “POST /campaigns/:id/schedule”Schedules a draft campaign for a future send (state → scheduled). Runs the same readiness preflight as start, plus time-validity checks. Auth: Bearer; role: any role.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Campaign id (camp_… or integer). |
| scheduled_at | body | string | yes | Target send time as a local (account-timezone) datetime, no offset — e.g. 2026-06-10T09:00 (the value a datetime-local input produces). It is interpreted in the account’s timezone (falling back to UTC if the account has none) and converted to UTC server-side. Must be in the future and at least 5 minutes from now. Not wrapped in a campaign object — sent as a top-level body key. |
Request
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "scheduled_at": "2026-06-10T09:00" }' \ https://platform.phishspot.com/api/v1/campaigns/42/scheduleResponse 200 OK — the campaign object with state: "scheduled".
Status codes
| Code | When |
|---|---|
| 200 | Campaign scheduled. |
| 403 | Campaign is not in a startable state (must be draft). |
| 404 | Campaign not found in the user’s accounts. |
| 422 | Scheduling failed. Body: { "errors": ["…"] } (flat array). Triggers: scheduled_at blank, unparseable datetime, time in the past, time less than 5 minutes from now, or the start-readiness preflight failing (same content/sender/domain/recipient/end-action checks as start). |
POST /campaigns/:id/cancel_schedule
Section titled “POST /campaigns/:id/cancel_schedule”Cancels a pending schedule, returning the campaign to draft and removing its queued send job. Only valid for a scheduled campaign. Auth: Bearer; role: any role.
No parameters beyond the bearer token and the path id.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Campaign id (camp_… or integer). |
Request
curl -X POST -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/campaigns/42/cancel_scheduleResponse 200 OK — the campaign object (state returned to draft).
Status codes
| Code | When |
|---|---|
| 200 | Schedule cancelled. |
| 403 | User not authorized (policy requires scheduled state). |
| 404 | Campaign not found in the user’s accounts. |
| 422 | Campaign is not currently scheduled. Body: { "error": "Campaign is not scheduled (state: <state>); nothing to cancel." } (note the singular error key here). |
GET /campaigns/:id/results
Section titled “GET /campaigns/:id/results”Returns aggregated campaign statistics: the overall engagement funnel plus per-group and per-department breakdowns. Use it to render dashboards and reports. Auth: Bearer; role: any role.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Campaign id (camp_… or integer). |
Request
curl -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/campaigns/42/resultsResponse 200 OK — statistics object.
| Field | Type | Description |
|---|---|---|
| campaign_id | integer | Campaign id. |
| name | string | Campaign name. |
| funnel | object | Overall engagement funnel (counts + rates). See sub-fields below. |
| groups | array | Per-group breakdown. Empty array if the campaign targets no groups. See sub-fields below. |
| departments | array | Per-department breakdown (contacts grouped by their department). Empty if no departments. Same numeric sub-fields as a group entry, with name but no id. |
funnel sub-fields:
| Field | Type | Description |
|---|---|---|
| sent | integer | Distinct contacts the email was successfully sent to. |
| opened | integer | Distinct contacts who opened. |
| clicked | integer | Distinct contacts who clicked. |
| submitted | integer | Distinct contacts who submitted data on the landing page. |
| trained | integer | Deliverables that reached the educated (training-completed) state. |
| replied | integer | Distinct contacts who replied to the phishing email (side-channel signal, not part of the click/submit funnel). |
| open_rate | float | opened / sent as a percentage (1 decimal). |
| click_rate | float | clicked / sent percentage. |
| submit_rate | float | submitted / sent percentage. |
| train_rate | float | trained / sent percentage. |
| reply_rate | float | replied / sent percentage. |
Each groups[] entry: name (string), id (integer), total_contacts (integer), sent, opened, clicked, submitted, trained (integers), and open_rate, click_rate, submit_rate, train_rate (floats).
{ "campaign_id": 42, "name": "Q2 Invoice Lure", "funnel": { "sent": 120, "opened": 84, "clicked": 37, "submitted": 12, "trained": 9, "replied": 3, "open_rate": 70.0, "click_rate": 30.8, "submit_rate": 10.0, "train_rate": 7.5, "reply_rate": 2.5 }, "groups": [ { "name": "Finance", "id": 3, "total_contacts": 60, "sent": 60, "opened": 45, "clicked": 22, "submitted": 8, "trained": 6, "open_rate": 75.0, "click_rate": 36.7, "submit_rate": 13.3, "train_rate": 10.0 } ], "departments": [ { "name": "Accounting", "total_contacts": 40, "sent": 40, "opened": 30, "clicked": 15, "submitted": 5, "trained": 4, "open_rate": 75.0, "click_rate": 37.5, "submit_rate": 12.5, "train_rate": 10.0 } ]}Status codes
| Code | When |
|---|---|
| 200 | Statistics returned. |
| 404 | Campaign not found in the user’s accounts. |
GET /campaigns/:id/recipients
Section titled “GET /campaigns/:id/recipients”Returns a paginated, filterable list of campaign recipients with their per-contact delivery stage, training status, and reply flag. Recipients are ordered by contact last name then first name. Auth: Bearer; role: any role.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Campaign id (camp_… or integer). |
| page | query | integer | no | 1-based page number; values below 1 are clamped to 1. Defaults to 1. Page size is fixed at 25. |
| stage | query | string | no | Filter by funnel stage: sent, opened, clicked, submitted, or trained. all (or omitted) returns everyone. Filters are cumulative-by-stage (e.g. opened includes those who later clicked/submitted/were educated). |
| replied | query | boolean | no | When truthy (true/1), restrict to contacts who replied to the email. |
| group_id | query | integer | no | Restrict to contacts in this group (must belong to the campaign’s account). |
| department | query | string | no | Restrict to contacts whose department matches this exact value. |
Request
curl -H "Authorization: Bearer $TOKEN" \ "https://platform.phishspot.com/api/v1/campaigns/42/recipients?stage=clicked&page=1"Response 200 OK — paginated recipients.
| Field | Type | Description |
|---|---|---|
| campaign_id | integer | Campaign id. |
| page | integer | Current page number. |
| per_page | integer | Page size (always 25). |
| total | integer | Total recipients matching the filters (across all pages). |
| recipients | array | Recipient rows (see sub-fields). |
Each recipients[] entry:
| Field | Type | Description |
|---|---|---|
| id | integer | Contact id. |
| contact_id | integer | Contact id (same value as id). |
| string | Contact email. | |
| full_name | string | Contact full name. |
| status | string | Delivery state: pending, sent, delivered, bounced, opened, clicked, submitted, or educated. |
| stage | string | Same value as status (alias). |
| training_status | string | not_started, in_progress, or completed. |
| replied | boolean | Whether the contact replied to the phishing email. |
{ "campaign_id": 42, "page": 1, "per_page": 25, "total": 37, "recipients": [ { "id": 901, "contact_id": 901, "email": "jane.doe@victimco.com", "full_name": "Jane Doe", "status": "clicked", "stage": "clicked", "training_status": "in_progress", "replied": false } ]}Status codes
| Code | When |
|---|---|
| 200 | Recipients returned. |
| 404 | Campaign not found in the user’s accounts, or a group_id/department lookup references a record outside the campaign’s account. |
GET /campaigns/:id/replies
Section titled “GET /campaigns/:id/replies”Returns a paginated list of inbound replies recipients sent back to the phishing email, newest first. Use it to surface engaged targets and review reply content. Auth: Bearer; role: any role.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Campaign id (camp_… or integer). |
| page | query | integer | no | 1-based page number; clamped to a minimum of 1. Defaults to 1. Page size is fixed at 25. |
Request
curl -H "Authorization: Bearer $TOKEN" \ "https://platform.phishspot.com/api/v1/campaigns/42/replies?page=1"Response 200 OK — paginated replies.
| Field | Type | Description |
|---|---|---|
| campaign_id | integer | Campaign id. |
| page | integer | Current page number. |
| per_page | integer | Page size (always 25). |
| total | integer | Total replies for the campaign. |
| replies | array | Reply rows (see sub-fields). |
Each replies[] entry:
| Field | Type | Description |
|---|---|---|
| id | integer | Reply id. |
| from_email | string | Sender (recipient) email address. |
| received_at | string | ISO8601 timestamp of when the reply was received. |
| subject | string | Reply subject line. |
| excerpt | string | Plain-text excerpt of the reply body (truncated). |
| attachments_count | integer | Number of attachments on the reply. |
{ "campaign_id": 42, "page": 1, "per_page": 25, "total": 3, "replies": [ { "id": 5501, "from_email": "jane.doe@victimco.com", "received_at": "2026-05-02T11:24:00Z", "subject": "Re: Your April invoice is ready", "excerpt": "Is this really from accounting? I don't recognize…", "attachments_count": 0 } ]}Status codes
| Code | When |
|---|---|
| 200 | Replies returned. |
| 404 | Campaign not found in the user’s accounts. |
GET /campaigns/:id/timeline
Section titled “GET /campaigns/:id/timeline”Returns the chronological event timeline for a single contact within the campaign (sent → opened → clicked → submitted → training, etc.). Use it to inspect one victim’s full interaction history. Auth: Bearer; role: any role.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Campaign id (camp_… or integer). |
| contact_id | query | integer | yes | Id of the contact whose timeline to return. Must belong to the campaign’s account. |
Request
curl -H "Authorization: Bearer $TOKEN" \ "https://platform.phishspot.com/api/v1/campaigns/42/timeline?contact_id=901"Response 200 OK — the contact’s event list, ordered oldest-first.
| Field | Type | Description |
|---|---|---|
| campaign_id | integer | Campaign id. |
| contact_id | integer | Contact id. |
| events | array | Events for this contact (see sub-fields). |
Each events[] entry:
| Field | Type | Description |
|---|---|---|
| genre | string | Event type: sent, delivered, bounced, opened, clicked, submitted_data, started_training, completed_training, failed_quiz, passed_quiz, or replied. |
| created_at | string | ISO8601 timestamp of the event. |
| metadata | object | null | Arbitrary event metadata (e.g. user agent, IP, quiz details), as stored. |
{ "campaign_id": 42, "contact_id": 901, "events": [ { "genre": "sent", "created_at": "2026-05-01T09:05:00Z", "metadata": {} }, { "genre": "opened", "created_at": "2026-05-01T09:41:00Z", "metadata": { "ua": "Mozilla/5.0" } }, { "genre": "clicked", "created_at": "2026-05-01T09:42:00Z", "metadata": { "ip": "203.0.113.7" } } ]}Status codes
| Code | When |
|---|---|
| 200 | Timeline returned. |
| 404 | Campaign not found in the user’s accounts, or contact_id does not reference a contact in the campaign’s account (a missing/invalid contact_id raises a not-found). |
27.5 Phishing templates
Section titled “27.5 Phishing templates”The phishing-template library is the catalog of ready-made phishing scenarios (email + landing page + post-click action) that an account can deploy into a real campaign. Templates come in two flavors: curated (platform-provided, shared with every account, read-only) and custom (created by an account, visible only to that account). Templates are organized into a tree of categories (up to three levels deep). Deploying a template creates a new draft campaign pre-filled with the template’s content — it never sends any email.
GET /accounts/:account_id/phishing_templates
Section titled “GET /accounts/:account_id/phishing_templates”Lists templates visible to an account, paginated 12 per page. Use the tab parameter to switch between the shared curated library and the account’s own custom templates, and category/search to narrow the results. Returns metadata only (no HTML blobs) — call the show endpoint for full content. Auth: Bearer; role: read (any role).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| account_id | path | string | yes | Account id (acct_… or integer). Must be an account the token’s user belongs to. |
| tab | query | string | no | Which library to list. custom returns this account’s own templates; any other value (or omitted) returns the shared curated library. Default: curated. |
| category | query | string or array | no | One or more category ids (tcat_… prefix ids or integers) to filter by. Matches templates assigned to the given category or any of its descendants. Pass multiple as repeated category[] params. Unrecognized ids are ignored. |
| search | query | string | no | Case-insensitive substring match against template name and description. |
| page | query | integer | no | 1-based page number. Values below 1 are clamped to 1. Default: 1. Page size is fixed at 12. |
Request
curl -H "Authorization: Bearer $TOKEN" \ "https://platform.phishspot.com/api/v1/accounts/11/phishing_templates?tab=curated&category=tcat_abc123&search=invoice&page=1"Response 200 OK — pagination envelope plus a templates array of template metadata.
| Field | Type | Description |
|---|---|---|
| tab | string | The resolved tab: "curated" or "custom". |
| page | integer | Current page number. |
| per_page | integer | Page size (always 12). |
| total | integer | Total number of templates matching the filters (across all pages). |
| templates | array | Array of template objects (see fields below). |
| templates[].id | integer | Raw numeric template id. |
| templates[].name | string | Template name. |
| templates[].description | string | null | Free-text description. |
| templates[].curated | boolean | true for platform-provided templates, false for account-owned. |
| templates[].draft | boolean | true if the template is an unpublished draft (missing required content). Drafts cannot be deployed. |
| templates[].email_subject | string | null | The phishing email subject line. |
| templates[].landing_page_enabled | boolean | Whether the template includes a hosted landing page. |
| templates[].created_at | string (ISO 8601) | Creation timestamp. |
| templates[].updated_at | string (ISO 8601) | Last-update timestamp. |
| templates[].template_id | string | Prefixed id (tmpl_…). Use this in the show/deploy paths. |
| templates[].categories | array | Categories this template is assigned to. |
| templates[].categories[].id | integer | Raw numeric category id. |
| templates[].categories[].category_id | string | Prefixed category id (tcat_…). |
| templates[].categories[].name | string | Localized category name (current request locale, falling back to English). |
{ "tab": "curated", "page": 1, "per_page": 12, "total": 37, "templates": [ { "id": 84, "name": "Unpaid Invoice Reminder", "description": "Spoofed accounts-payable invoice with a credential-harvesting login page.", "curated": true, "draft": false, "email_subject": "Action required: invoice #44021 is overdue", "landing_page_enabled": true, "created_at": "2026-01-14T09:12:00.000Z", "updated_at": "2026-03-02T16:40:11.000Z", "template_id": "tmpl_8x2k9q", "categories": [ { "id": 5, "category_id": "tcat_abc123", "name": "Finance" } ] } ]}Status codes
| Code | When |
|---|---|
| 200 | Templates listed (the array may be empty). |
| 404 | account_id is not an account the token’s user belongs to. Body: {"error":"Account not found"}. |
GET /accounts/:account_id/phishing_template_categories
Section titled “GET /accounts/:account_id/phishing_template_categories”Returns the full category tree (roots with nested children, up to three levels) for the template-library filter UI. Useful for building a category picker before calling the templates list with category=. Auth: Bearer; role: read (any role).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| account_id | path | string | yes | Account id (acct_… or integer). Must be an account the token’s user belongs to. |
(Categories are global, not account-scoped — the account_id only gates access. No other parameters.)
Request
curl -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/accounts/11/phishing_template_categoriesResponse 200 OK — a categories array of root categories, each recursively embedding its children.
| Field | Type | Description |
|---|---|---|
| categories | array | Root categories, ordered by position. |
| categories[].id | integer | Raw numeric category id. |
| categories[].category_id | string | Prefixed category id (tcat_…). |
| categories[].name | string | Localized category name (request locale, falling back to English). |
| categories[].slug | string | URL-safe slug (unique, derived from the English name). |
| categories[].depth | integer | Tree depth: 0 for roots, 1 for children, 2 for grandchildren. |
| categories[].is_leaf | boolean | true when the category has no children. |
| categories[].children | array | Nested child categories with the same shape (empty array for leaves). |
{ "categories": [ { "id": 1, "category_id": "tcat_root01", "name": "Finance", "slug": "finance", "depth": 0, "is_leaf": false, "children": [ { "id": 5, "category_id": "tcat_abc123", "name": "Invoices", "slug": "invoices", "depth": 1, "is_leaf": true, "children": [] } ] } ]}Status codes
| Code | When |
|---|---|
| 200 | Category tree returned (may be an empty array). |
| 404 | account_id is not an account the token’s user belongs to. Body: {"error":"Account not found"}. |
GET /phishing_templates/:id
Section titled “GET /phishing_templates/:id”Returns a single template with its full content — email body, landing-page HTML/CSS, and post-click (end action) configuration. Use this to render a preview or to inspect what a deploy will copy into a campaign. This route is shallow (no account_id in the path); the token’s user must be able to see the template (their own custom templates plus all curated ones). Auth: Bearer; role: read (any role).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Template id (tmpl_… or integer). |
No parameters beyond the bearer token.
Request
curl -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/phishing_templates/tmpl_8x2k9qResponse 200 OK — the full template object.
| Field | Type | Description |
|---|---|---|
| id | integer | Raw numeric template id. |
| name | string | Template name. |
| description | string | null | Free-text description. |
| curated | boolean | true for platform-provided templates, false for account-owned. |
| draft | boolean | true if unpublished. Drafts cannot be deployed. |
| email_subject | string | null | Phishing email subject line (may contain merge tags). |
| email_content | string | null | Full phishing email HTML body. |
| landing_html | string | null | Landing-page HTML. |
| landing_css | string | null | Landing-page CSS. |
| landing_page_enabled | boolean | Whether a hosted landing page is included. |
| end_action_type | string | What happens after a victim submits the landing page. One of nothing, redirect_to_course, message_page, redirect_to_url. |
| end_action_url | string | null | Target URL when end_action_type is redirect_to_url (must be http/https). |
| end_action_html | string | null | HTML shown when end_action_type is message_page (e.g. the awareness page). |
| created_at | string (ISO 8601) | Creation timestamp. |
| updated_at | string (ISO 8601) | Last-update timestamp. |
| template_id | string | Prefixed id (tmpl_…). |
| course_id | integer | null | Linked e-learning course id (used when end_action_type is redirect_to_course). |
| publishable | boolean | true when all required fields (name, subject, email body, landing HTML) are present. |
| categories | array | Assigned categories: each with id (integer), category_id (tcat_…), and name. |
{ "id": 84, "name": "Unpaid Invoice Reminder", "description": "Spoofed accounts-payable invoice with a credential-harvesting login page.", "curated": true, "draft": false, "email_subject": "Action required: invoice #44021 is overdue", "email_content": "<html><body><p>Dear {{first_name}}, your invoice is overdue…</p></body></html>", "landing_html": "<form action=\"#\">…</form>", "landing_css": "body { font-family: sans-serif; }", "landing_page_enabled": true, "end_action_type": "message_page", "end_action_url": null, "end_action_html": "<h1>You've been phished by a simulation.</h1>", "created_at": "2026-01-14T09:12:00.000Z", "updated_at": "2026-03-02T16:40:11.000Z", "template_id": "tmpl_8x2k9q", "course_id": null, "publishable": true, "categories": [ { "id": 5, "category_id": "tcat_abc123", "name": "Finance" } ]}Status codes
| Code | When |
|---|---|
| 200 | Template returned. |
| 403 | The template is not visible to the token’s user (another account’s custom template). Body: {"error":"You are not authorized to perform this action"}. |
| 404 | No template matches id. Body: {"error":"Resource not found"}. |
POST /phishing_templates/:id/deploy
Section titled “POST /phishing_templates/:id/deploy”Creates a new draft campaign in the target account, pre-filled with the template’s content (email subject/body, landing HTML/CSS, end action, course). This route is shallow, so the deploying account is passed in the body as account_id. With quick_launch=true it additionally adds all of the account’s contacts as recipients and advances the campaign to the review step. This endpoint never sends any email — a human launches the campaign from the PhishSpot UI. Drafts cannot be deployed. Auth: Bearer; role: any member of the target account.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Template id to deploy (tmpl_… or integer). Must not be a draft template. |
| account_id | body | string | yes | Account to create the campaign in (acct_… or integer). Must be an account the token’s user belongs to. |
| quick_launch | body | boolean | no | When truthy (true, "1", etc.), bulk-adds every account contact as a recipient and marks wizard steps 1–5 complete so the campaign lands on the review step. Requires an active sending domain and at least one contact. Default: false. |
Request
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "account_id": "acct_4f8a2c", "quick_launch": true }' \ https://platform.phishspot.com/api/v1/phishing_templates/tmpl_8x2k9q/deployResponse 201 Created — the newly created campaign (same shape as the campaign show endpoint). A freshly deployed campaign is in the draft state, so the statistics block is omitted (it only appears once a campaign is in progress, paused, or done).
| Field | Type | Description |
|---|---|---|
| id | integer | Raw numeric campaign id. |
| account_id | integer | Owning account id. |
| name | string | Auto-generated name: "<Template name> - YYYY-MM-DD HH:MM:SS" (with a numeric suffix on collision). |
| state | string | Lifecycle state — draft immediately after deploy. One of draft, in_progress, paused, cancelled, done, scheduled. |
| delivery_mode | string | null | immediate, scheduled, or staggered (not set by deploy). |
| delivery_schedule | object | null | Delivery schedule config (not set by deploy). |
| created_at | string (ISO 8601) | Creation timestamp. |
| updated_at | string (ISO 8601) | Last-update timestamp. |
| email_subject | string | null | Copied from the template. |
| email_content | string | null | Copied from the template. |
| landing_html | string | null | Copied from the template. |
| domain | string | null | Sending/landing platform domain name (auto-selected from the account’s available domains, may be null). |
| course_id | integer | null | Linked course id (template’s course, or the account default). |
| groups | array | Contact groups on the campaign — empty right after deploy. Each: id, name. |
| can_start | boolean | Whether the campaign can transition to start. |
| can_pause | boolean | Whether the campaign can be paused. |
| can_cancel | boolean | Whether the campaign can be cancelled. |
{ "id": 512, "account_id": 11, "name": "Unpaid Invoice Reminder - 2026-06-02 14:30:07", "state": "draft", "delivery_mode": null, "delivery_schedule": null, "created_at": "2026-06-02T14:30:07.000Z", "updated_at": "2026-06-02T14:30:07.000Z", "email_subject": "Action required: invoice #44021 is overdue", "email_content": "<html><body><p>Dear {{first_name}}…</p></body></html>", "landing_html": "<form action=\"#\">…</form>", "domain": "officelogin.in", "course_id": null, "groups": [], "can_start": false, "can_pause": false, "can_cancel": false}Status codes
| Code | When |
|---|---|
| 201 | Campaign created from the template. |
| 403 | The template is a draft (drafts cannot be deployed), or it is not visible to the token’s user. Body: {"error":"You are not authorized to perform this action"}. |
| 404 | No template matches id, or the body account_id is not an account the token’s user belongs to. Body: {"error":"Resource not found"} (template) / {"error":"Account not found"} (account). |
| 422 | quick_launch=true but the account has no active sending domain ("Quick launch needs an active sending domain for this account.") or no contacts ("Quick launch needs at least one contact in the account."). Body: {"error":"<message>"}. |
27.6 Contacts & groups
Section titled “27.6 Contacts & groups”Contacts are the employees you target with phishing simulations; groups are named collections used to scope campaigns. Both are account-scoped: collection actions are nested under /accounts/:account_id/…, while reads/writes on an individual record use the shallow /contacts/:id and /groups/:id routes. Membership in the account is required for every endpoint — all policy checks pass for any account member (read and write alike), so there is no admin/editor distinction here. The only write gate is that a group participating in an active campaign (in_progress or paused) is locked and cannot be updated, deleted, or have its membership changed.
GET /accounts/:account_id/contacts
Section titled “GET /accounts/:account_id/contacts”Lists every contact in the account, ordered by last name then first name, with each contact’s groups inlined. Use it to page through or sync your roster. Auth: Bearer; role: read (any role).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| account_id | path | string | yes | Account id (acct_… or integer). |
Request
curl -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/accounts/11/contactsResponse 200 OK — a JSON array of contact objects (see the contact fields below).
| Field | Type | Description |
|---|---|---|
| id | integer | Contact primary key. |
| account_id | integer | Owning account id. |
| first_name | string | Given name. |
| last_name | string|null | Surname. |
| string | Email address (unique per account). | |
| telephone | string|null | Phone number. |
| created_at | string | ISO 8601 timestamp. |
| updated_at | string | ISO 8601 timestamp. |
| full_name | string | Convenience: "first_name last_name" trimmed. |
| groups | array | Groups this contact belongs to; each { id, name }. |
| groups[].id | integer | Group id. |
| groups[].name | string | Group name (normalized, snake_case for manual groups). |
[ { "id": 501, "account_id": 11, "first_name": "Ada", "last_name": "Kowalska", "email": "ada.kowalska@example.com", "telephone": "+48 600 123 456", "created_at": "2026-05-01T09:30:00.000Z", "updated_at": "2026-05-12T14:02:11.000Z", "full_name": "Ada Kowalska", "groups": [ { "id": 90, "name": "finance" } ] }]Status codes
| Code | When |
|---|---|
| 200 | Contacts returned (possibly an empty array). |
| 404 | account_id is not an account the token’s user belongs to. |
POST /accounts/:account_id/contacts
Section titled “POST /accounts/:account_id/contacts”Creates a single contact in the account. Use the import endpoint instead for bulk loads. Auth: Bearer; role: read (any role).
Parameters
Body params are wrapped in a contact object.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| account_id | path | string | yes | Account id (acct_… or integer). |
| first_name | body | string | yes | Given name (max 255). Required by the model. |
| body | string | yes | Email address. Must match a standard email format and be unique within the account (case-insensitive). Max 255. | |
| last_name | body | string | no | Surname (max 255). |
| telephone | body | string | no | Phone number (max 50). Must match +CC (NNN) NNN-NNNN-style formats; blank allowed. |
| group_ids | body | array | no | Array of group ids to attach the contact to on creation. |
Request
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "contact": { "first_name": "Ada", "last_name": "Kowalska", "email": "ada.kowalska@example.com", "telephone": "+48 600 123 456", "group_ids": [90] } }' \ https://platform.phishspot.com/api/v1/accounts/11/contactsResponse 201 Created — the created contact, using the same fields as the list endpoint above.
{ "id": 501, "account_id": 11, "first_name": "Ada", "last_name": "Kowalska", "email": "ada.kowalska@example.com", "telephone": "+48 600 123 456", "created_at": "2026-05-01T09:30:00.000Z", "updated_at": "2026-05-01T09:30:00.000Z", "full_name": "Ada Kowalska", "groups": [ { "id": 90, "name": "finance" } ]}Status codes
| Code | When |
|---|---|
| 201 | Contact created. |
| 400 | Body is missing the top-level contact key. |
| 404 | account_id is not an account the token’s user belongs to. |
| 422 | Validation failed (e.g. missing first_name, missing/malformed email, or duplicate email within the account). Body is { "errors": { … } }. |
POST /accounts/:account_id/contacts/import
Section titled “POST /accounts/:account_id/contacts/import”Bulk-imports contacts into the account from CSV. Existing contacts (matched by email) are updated with non-blank values; new ones are created; groups named in the data are created and associations are made. Provide either raw CSV text in csv or a JSON array in contacts — the array is converted to CSV server-side using the canonical header order. Auth: Bearer; role: read (any role).
The canonical CSV header order is: first_name, last_name, email, telephone, groups, department, title, location. In the groups column, multiple groups are separated by | (a pipe). When using the JSON contacts form, each row’s groups may be an array (e.g. ["finance","exec"]) which is joined with | automatically.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| account_id | path | string | yes | Account id (acct_… or integer). |
| csv | body | string | conditional | Raw CSV text with the canonical header row. Required if contacts is omitted. Takes precedence if both are given. |
| contacts | body | array | conditional | Array of row objects keyed by the canonical headers. Required if csv is omitted. Each row’s groups may be a string or an array of group names. |
Request
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "contacts": [ { "first_name": "Ada", "last_name": "Kowalska", "email": "ada.kowalska@example.com", "telephone": "+48600123456", "groups": ["finance"], "department": "Finance", "title": "Analyst", "location": "Warsaw" }, { "first_name": "Jan", "email": "jan.nowak@example.com", "groups": ["finance", "exec"] } ] }' \ https://platform.phishspot.com/api/v1/accounts/11/contacts/importResponse 200 OK — a summary of how the rows were processed.
| Field | Type | Description |
|---|---|---|
| created | integer | Number of new contacts inserted. |
| updated | integer | Number of existing contacts (matched by email) updated with new non-blank values. |
| failed | integer | Number of rows rejected as invalid. (A downloadable failed-rows CSV report is attached to the account.) |
{ "created": 1, "updated": 1, "failed": 0}Status codes
| Code | When |
|---|---|
| 200 | Import ran; returns the {created, updated, failed} summary. |
| 404 | account_id is not an account the token’s user belongs to. |
| 422 | Neither csv nor contacts was provided. Body is { "error": "Provide either csv or contacts." }. |
GET /contacts/:id
Section titled “GET /contacts/:id”Fetches a single contact by id, scoped to the token user’s accounts. Use it to read one contact without listing the whole account. Auth: Bearer; role: read (any role).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Contact id (cont_… or integer). |
No parameters beyond the bearer token and path id.
Request
curl -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/contacts/cont_abc123Response 200 OK — the contact, using the same fields as the list endpoint.
{ "id": 501, "account_id": 11, "first_name": "Ada", "last_name": "Kowalska", "email": "ada.kowalska@example.com", "telephone": "+48 600 123 456", "created_at": "2026-05-01T09:30:00.000Z", "updated_at": "2026-05-12T14:02:11.000Z", "full_name": "Ada Kowalska", "groups": [ { "id": 90, "name": "finance" } ]}Status codes
| Code | When |
|---|---|
| 200 | Contact found. |
| 404 | No contact with that id in any account the token’s user belongs to. |
PATCH /contacts/:id
Section titled “PATCH /contacts/:id”Updates a single contact. Send only the fields you want to change, wrapped in a contact object. Auth: Bearer; role: read (any role).
Parameters
Body params are wrapped in a contact object.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Contact id (cont_… or integer). |
| first_name | body | string | no | Given name (max 255). Cannot be cleared to blank — it is required. |
| last_name | body | string | no | Surname (max 255). |
| body | string | no | Email address. Must stay valid and unique per account (case-insensitive). Max 255. | |
| telephone | body | string | no | Phone number (max 50, format-validated; blank allowed). |
| group_ids | body | array | no | Replaces the contact’s group membership with this exact set of group ids. |
Request
curl -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "contact": { "title": "Senior Analyst", "group_ids": [90, 91] } }' \ https://platform.phishspot.com/api/v1/contacts/cont_abc123Response 200 OK — the updated contact, using the same fields as the list endpoint.
{ "id": 501, "account_id": 11, "first_name": "Ada", "last_name": "Kowalska", "email": "ada.kowalska@example.com", "telephone": "+48 600 123 456", "created_at": "2026-05-01T09:30:00.000Z", "updated_at": "2026-05-20T08:15:00.000Z", "full_name": "Ada Kowalska", "groups": [ { "id": 90, "name": "finance" }, { "id": 91, "name": "exec" } ]}Status codes
| Code | When |
|---|---|
| 200 | Contact updated. |
| 400 | Body is missing the top-level contact key. |
| 404 | No contact with that id in any account the token’s user belongs to. |
| 422 | Validation failed (blank first_name, invalid/duplicate email, bad telephone format). Body is { "errors": { … } }. |
DELETE /contacts/:id
Section titled “DELETE /contacts/:id”Permanently deletes a contact and its group memberships, deliverables, events, and results. Auth: Bearer; role: read (any role).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Contact id (cont_… or integer). |
No parameters beyond the bearer token and path id.
Request
curl -X DELETE -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/contacts/cont_abc123Response 204 No Content — empty body.
Status codes
| Code | When |
|---|---|
| 204 | Contact deleted. |
| 404 | No contact with that id in any account the token’s user belongs to. |
GET /accounts/:account_id/groups
Section titled “GET /accounts/:account_id/groups”Lists every group in the account, ordered by name, with each group’s contacts inlined. Auth: Bearer; role: read (any role).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| account_id | path | string | yes | Account id (acct_… or integer). |
Response 200 OK — a JSON array of group objects (see the group fields below).
| Field | Type | Description |
|---|---|---|
| id | integer | Group primary key. |
| account_id | integer | Owning account id. |
| name | string | Group name. For manual groups this is normalized to snake_case (spaces → underscores, non-alphanumerics stripped, lowercased). |
| contact_count | integer | Cached count of contacts in the group. |
| created_at | string | ISO 8601 timestamp. |
| updated_at | string | ISO 8601 timestamp. |
| contacts | array | Members of the group. |
| contacts[].id | integer | Contact id. |
| contacts[].email | string | Contact email. |
| contacts[].first_name | string | Contact given name. |
| contacts[].last_name | string|null | Contact surname. |
| contacts[].full_name | string | "first_name last_name" trimmed. |
[ { "id": 90, "account_id": 11, "name": "finance", "contact_count": 2, "created_at": "2026-04-10T11:00:00.000Z", "updated_at": "2026-05-20T08:15:00.000Z", "contacts": [ { "id": 501, "email": "ada.kowalska@example.com", "first_name": "Ada", "last_name": "Kowalska", "full_name": "Ada Kowalska" } ] }]Status codes
| Code | When |
|---|---|
| 200 | Groups returned (possibly an empty array). |
| 404 | account_id is not an account the token’s user belongs to. |
POST /accounts/:account_id/groups
Section titled “POST /accounts/:account_id/groups”Creates a group in the account. Manual group names are normalized to snake_case server-side. Auth: Bearer; role: read (any role).
Parameters
Body params are wrapped in a group object.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| account_id | path | string | yes | Account id (acct_… or integer). |
| name | body | string | yes | Group name (max 255). Normalized to snake_case; must be unique within the account (case-insensitive, compared after normalization). |
| description | body | string | no | Free-text description. Permitted by the controller (the model has no description column, so it is accepted but not persisted/returned). |
| contact_ids | body | array | no | Array of contact ids to add as members on creation. |
Request
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "group": { "name": "Finance Team", "contact_ids": [501, 502] } }' \ https://platform.phishspot.com/api/v1/accounts/11/groupsResponse 201 Created — the created group, using the same fields as the list endpoint above. Note "Finance Team" is stored and returned as "finance_team".
{ "id": 90, "account_id": 11, "name": "finance_team", "contact_count": 2, "created_at": "2026-04-10T11:00:00.000Z", "updated_at": "2026-04-10T11:00:00.000Z", "contacts": [ { "id": 501, "email": "ada.kowalska@example.com", "first_name": "Ada", "last_name": "Kowalska", "full_name": "Ada Kowalska" }, { "id": 502, "email": "jan.nowak@example.com", "first_name": "Jan", "last_name": "Nowak", "full_name": "Jan Nowak" } ]}Status codes
| Code | When |
|---|---|
| 201 | Group created. |
| 400 | Body is missing the top-level group key. |
| 404 | account_id is not an account the token’s user belongs to. |
| 422 | Validation failed (blank name, or a name that normalizes to a duplicate within the account). Body is { "errors": { … } }. |
GET /groups/:id
Section titled “GET /groups/:id”Fetches a single group by id, scoped to the token user’s accounts. Auth: Bearer; role: read (any role).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Group id (grp_… or integer). |
No parameters beyond the bearer token and path id.
Request
curl -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/groups/grp_xyz789Response 200 OK — the group, using the same fields as the list endpoint.
{ "id": 90, "account_id": 11, "name": "finance", "contact_count": 1, "created_at": "2026-04-10T11:00:00.000Z", "updated_at": "2026-05-20T08:15:00.000Z", "contacts": [ { "id": 501, "email": "ada.kowalska@example.com", "first_name": "Ada", "last_name": "Kowalska", "full_name": "Ada Kowalska" } ]}Status codes
| Code | When |
|---|---|
| 200 | Group found. |
| 404 | No group with that id in any account the token’s user belongs to. |
PATCH /groups/:id
Section titled “PATCH /groups/:id”Updates a group’s name (and, optionally, replaces its membership). Blocked if the group is locked by an active campaign. Auth: Bearer; role: read (any role).
Parameters
Body params are wrapped in a group object.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Group id (grp_… or integer). |
| name | body | string | no | New group name (max 255). Normalized to snake_case; must remain unique within the account. |
| description | body | string | no | Accepted but not persisted (no model column). |
| contact_ids | body | array | no | Replaces the group’s membership with this exact set of contact ids. |
Request
curl -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "group": { "name": "Finance and Ops", "contact_ids": [501, 503] } }' \ https://platform.phishspot.com/api/v1/groups/grp_xyz789Response 200 OK — the updated group, using the same fields as the list endpoint.
{ "id": 90, "account_id": 11, "name": "finance_and_ops", "contact_count": 2, "created_at": "2026-04-10T11:00:00.000Z", "updated_at": "2026-05-21T10:00:00.000Z", "contacts": [ { "id": 501, "email": "ada.kowalska@example.com", "first_name": "Ada", "last_name": "Kowalska", "full_name": "Ada Kowalska" }, { "id": 503, "email": "ola.wisniewska@example.com", "first_name": "Ola", "last_name": "Wisniewska", "full_name": "Ola Wisniewska" } ]}Status codes
| Code | When |
|---|---|
| 200 | Group updated. |
| 400 | Body is missing the top-level group key. |
| 403 | The group is locked (used in an in_progress/paused campaign) so it cannot be modified. |
| 404 | No group with that id in any account the token’s user belongs to. |
| 422 | Validation failed (blank name or a name that normalizes to a duplicate). Body is { "errors": { … } }. |
DELETE /groups/:id
Section titled “DELETE /groups/:id”Permanently deletes a group and its contact-group / campaign-group associations (the contacts themselves are not deleted). Blocked if the group is locked by an active campaign. Auth: Bearer; role: read (any role).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Group id (grp_… or integer). |
No parameters beyond the bearer token and path id.
Request
curl -X DELETE -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/groups/grp_xyz789Response 204 No Content — empty body.
Status codes
| Code | When |
|---|---|
| 204 | Group deleted. |
| 403 | The group is locked (used in an in_progress/paused campaign) so it cannot be deleted. |
| 404 | No group with that id in any account the token’s user belongs to. |
POST /groups/:id/add_contacts
Section titled “POST /groups/:id/add_contacts”Adds one or more contacts to a group. Contact ids are resolved against the group’s own account — ids that belong to another account, or that don’t exist, are silently dropped. Contacts already in the group are skipped. Blocked if the group is locked. Auth: Bearer; role: read (any role).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Group id (grp_… or integer). |
| contact_ids | body | array | yes | Contact ids (cont_… or integers) to add. Ids outside the group’s account or non-existent are ignored. |
Request
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "contact_ids": [501, "cont_def456"] }' \ https://platform.phishspot.com/api/v1/groups/grp_xyz789/add_contactsResponse 200 OK — the updated group (after reload), using the same fields as the list endpoint. The response does not include a separate count of how many were added; compare contact_count / contacts before and after.
{ "id": 90, "account_id": 11, "name": "finance", "contact_count": 2, "created_at": "2026-04-10T11:00:00.000Z", "updated_at": "2026-05-22T09:00:00.000Z", "contacts": [ { "id": 501, "email": "ada.kowalska@example.com", "first_name": "Ada", "last_name": "Kowalska", "full_name": "Ada Kowalska" }, { "id": 540, "email": "marek.zielinski@example.com", "first_name": "Marek", "last_name": "Zielinski", "full_name": "Marek Zielinski" } ]}Status codes
| Code | When |
|---|---|
| 200 | Returns the updated group (even if every supplied id was dropped/already present — it just won’t change). |
| 403 | The group is locked (used in an in_progress/paused campaign). |
| 404 | No group with that id in any account the token’s user belongs to. |
DELETE /groups/:id/remove_contacts
Section titled “DELETE /groups/:id/remove_contacts”Removes one or more contacts from a group. Contact ids are resolved against the group’s account; ids not in the group (or outside the account) are ignored. Blocked if the group is locked. Auth: Bearer; role: read (any role).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Group id (grp_… or integer). |
| contact_ids | body | array | yes | Contact ids (cont_… or integers) to remove from the group. |
Request
curl -X DELETE -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "contact_ids": [540] }' \ https://platform.phishspot.com/api/v1/groups/grp_xyz789/remove_contactsResponse 200 OK — the updated group (after reload), using the same fields as the list endpoint.
{ "id": 90, "account_id": 11, "name": "finance", "contact_count": 1, "created_at": "2026-04-10T11:00:00.000Z", "updated_at": "2026-05-22T09:10:00.000Z", "contacts": [ { "id": 501, "email": "ada.kowalska@example.com", "first_name": "Ada", "last_name": "Kowalska", "full_name": "Ada Kowalska" } ]}Status codes
| Code | When |
|---|---|
| 200 | Returns the updated group (ids not in the group are simply ignored). |
| 403 | The group is locked (used in an in_progress/paused campaign). |
| 404 | No group with that id in any account the token’s user belongs to. |
27.7 Deliverables, events & results
Section titled “27.7 Deliverables, events & results”These three resources record the per-recipient telemetry of a campaign. A deliverable is the join between a campaign and a contact (one row per recipient) and tracks its position in the engagement funnel via state. An event is an immutable-ish timeline entry (sent, opened, clicked, …) keyed by genre. A result stores a contact’s answer/score for a single e-learning block.
All endpoints in this section authorize with the resource’s Pundit policy, which permits any team member (any role) to read, create, update, and destroy. There is no admin/editor gate. Listing and creation are nested under an account (/accounts/:account_id/…); show/update/destroy are shallow (/deliverables/:id etc.) and are scoped to accounts the token’s user belongs to — requesting a record outside those accounts returns 404, never another tenant’s data.
GET /api/v1/accounts/:account_id/deliverables
Section titled “GET /api/v1/accounts/:account_id/deliverables”Lists every deliverable for the account, newest first, optionally filtered to one campaign. Use it to pull the recipient roster and funnel state of a campaign. Auth: Bearer; role: read (any role).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| account_id | path | string | yes | Account id (acct_… or integer). |
| campaign_id | query | string | no | Restrict to one campaign (camp_… or integer). When omitted, all deliverables for the account are returned. |
Request
curl -H "Authorization: Bearer $TOKEN" \ "https://platform.phishspot.com/api/v1/accounts/11/deliverables?campaign_id=42"Response 200 OK — JSON array of deliverable objects.
| Field | Type | Description |
|---|---|---|
| id | integer | Deliverable id. |
| campaign_id | integer | Owning campaign. |
| contact_id | integer | Targeted contact. |
| state | string | Funnel state (see enum below). |
| user_agent | string | null | User-agent captured on open/click, if any. |
| ip_address | string | null | IP captured on open/click, if any. |
| created_at | string | ISO 8601 timestamp. |
| updated_at | string | ISO 8601 timestamp. |
| campaign | object | null | Present when the campaign loads: { id, name, account_id }. |
| contact | object | null | Present when the contact loads: { id, email, first_name, last_name, full_name }. |
| events | array | Present only when the contact has events in this campaign; each item is { id, genre, created_at }. |
state is one of: pending (not yet sent), sent, delivered, bounced, opened, clicked, submitted (data entered on landing page), educated (completed training).
[ { "id": 5012, "campaign_id": 42, "contact_id": 880, "state": "clicked", "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "ip_address": "203.0.113.7", "created_at": "2026-05-30T09:12:44.000Z", "updated_at": "2026-05-30T10:01:08.000Z", "campaign": { "id": 42, "name": "Q2 Invoice Lure", "account_id": 11 }, "contact": { "id": 880, "email": "jan.kowalski@acme.test", "first_name": "Jan", "last_name": "Kowalski", "full_name": "Jan Kowalski" }, "events": [ { "id": 9001, "genre": "sent", "created_at": "2026-05-30T09:12:44.000Z" }, { "id": 9044, "genre": "opened", "created_at": "2026-05-30T09:58:21.000Z" }, { "id": 9051, "genre": "clicked", "created_at": "2026-05-30T10:01:08.000Z" } ] }]Status codes
| Code | When |
|---|---|
| 200 | Deliverables returned. |
| 403 | Token user is not authorized to view the account (account.show? denied). |
| 404 | account_id does not belong to the token user. |
POST /api/v1/accounts/:account_id/deliverables
Section titled “POST /api/v1/accounts/:account_id/deliverables”Creates a deliverable, linking a contact to a campaign. The account_id is derived from the campaign automatically (the supplied account_id path segment selects the account context). Auth: Bearer; role: read (any role).
Parameters
All body params are wrapped in a deliverable object.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| account_id | path | string | yes | Account id (acct_… or integer). |
| deliverable.campaign_id | body | integer | yes | Campaign to attach to. Validated presence. |
| deliverable.contact_id | body | integer | yes | Contact being targeted. Validated presence. |
| deliverable.state | body | string | no | Funnel state; defaults to pending. One of the state enum values. Validated presence. |
| deliverable.name | body | string | no | Accepted by strong params but not persisted (no name column). |
Request
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "deliverable": { "campaign_id": 42, "contact_id": 880, "state": "pending" } }' \ https://platform.phishspot.com/api/v1/accounts/11/deliverablesResponse 201 Created — the created deliverable (same shape as the show/index object above).
{ "id": 5099, "campaign_id": 42, "contact_id": 880, "state": "pending", "user_agent": null, "ip_address": null, "created_at": "2026-06-02T08:00:00.000Z", "updated_at": "2026-06-02T08:00:00.000Z", "campaign": { "id": 42, "name": "Q2 Invoice Lure", "account_id": 11 }, "contact": { "id": 880, "email": "jan.kowalski@acme.test", "first_name": "Jan", "last_name": "Kowalski", "full_name": "Jan Kowalski" }}Status codes
| Code | When |
|---|---|
| 201 | Deliverable created. |
| 404 | account_id does not belong to the token user. |
| 422 | Validation failed (missing campaign_id/contact_id/state, or an invalid state value). Body: { "errors": { … } }. |
GET /api/v1/deliverables/:id
Section titled “GET /api/v1/deliverables/:id”Fetches a single deliverable, including its campaign, contact, and event timeline. Auth: Bearer; role: read (any role).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Deliverable id (delv_… or integer). |
Request
curl -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/deliverables/5012Response 200 OK — single deliverable object (same fields as the index item above).
{ "id": 5012, "campaign_id": 42, "contact_id": 880, "state": "clicked", "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "ip_address": "203.0.113.7", "created_at": "2026-05-30T09:12:44.000Z", "updated_at": "2026-05-30T10:01:08.000Z", "campaign": { "id": 42, "name": "Q2 Invoice Lure", "account_id": 11 }, "contact": { "id": 880, "email": "jan.kowalski@acme.test", "first_name": "Jan", "last_name": "Kowalski", "full_name": "Jan Kowalski" }, "events": [ { "id": 9001, "genre": "sent", "created_at": "2026-05-30T09:12:44.000Z" } ]}Status codes
| Code | When |
|---|---|
| 200 | Deliverable returned. |
| 404 | No deliverable with that id in an account the token user belongs to. |
PATCH /api/v1/deliverables/:id
Section titled “PATCH /api/v1/deliverables/:id”Updates a deliverable — typically to advance its state or re-link campaign/contact. Auth: Bearer; role: read (any role).
Parameters
Body params are wrapped in a deliverable object; send only the fields you want to change.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Deliverable id (delv_… or integer). |
| deliverable.state | body | string | no | New funnel state (one of the state enum values). |
| deliverable.campaign_id | body | integer | no | Reassign campaign (presence still enforced — cannot be blanked). |
| deliverable.contact_id | body | integer | no | Reassign contact (presence still enforced). |
| deliverable.name | body | string | no | Accepted but not persisted (no column). |
Request
curl -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "deliverable": { "state": "submitted" } }' \ https://platform.phishspot.com/api/v1/deliverables/5012Response 200 OK — the updated deliverable object (same shape as show).
Status codes
| Code | When |
|---|---|
| 200 | Deliverable updated. |
| 404 | No deliverable with that id in an account the token user belongs to. |
| 422 | Validation failed (e.g. invalid state, or campaign_id/contact_id blanked). Body: { "errors": { … } }. |
DELETE /api/v1/deliverables/:id
Section titled “DELETE /api/v1/deliverables/:id”Permanently deletes a deliverable (and its dependent campaign_replies). Auth: Bearer; role: read (any role).
No parameters beyond the bearer token and the path id.
Request
curl -X DELETE -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/deliverables/5012Response 204 No Content — empty body on success.
Status codes
| Code | When |
|---|---|
| 204 | Deliverable deleted. |
| 404 | No deliverable with that id in an account the token user belongs to. |
GET /api/v1/accounts/:account_id/events
Section titled “GET /api/v1/accounts/:account_id/events”Lists the account’s events newest-first, with optional filtering by campaign, contact, and genre. Use it to reconstruct an engagement timeline. Auth: Bearer; role: read (any role).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| account_id | path | string | yes | Account id (acct_… or integer). |
| campaign_id | query | string | no | Restrict to one campaign (camp_… or integer). |
| contact_id | query | string | no | Restrict to one contact (cont_… or integer). |
| genre | query | string | no | Restrict to one genre (see enum below). |
genre is one of: sent, delivered, bounced, opened, clicked, submitted_data (data submitted on landing page), started_training, completed_training, failed_quiz, passed_quiz, replied (recipient replied to the phishing email).
Request
curl -H "Authorization: Bearer $TOKEN" \ "https://platform.phishspot.com/api/v1/accounts/11/events?campaign_id=42&genre=clicked"Response 200 OK — JSON array of event objects.
| Field | Type | Description |
|---|---|---|
| id | integer | Event id. |
| account_id | integer | Owning account. |
| campaign_id | integer | Campaign the event belongs to. |
| contact_id | integer | Contact the event belongs to. |
| genre | string | Event genre (see enum above). |
| metadata | object | Free-form JSON (e.g. ip_address, user_agent, submitted fields, quiz data). Defaults to {}. |
| created_at | string | ISO 8601 timestamp. |
| updated_at | string | ISO 8601 timestamp. |
| genre_display_name | string | Humanized genre (e.g. "Submitted data"); present only when genre is set. |
| ip_address | string | Convenience copy of metadata.ip_address; present only when set. |
| user_agent | string | Convenience copy of metadata.user_agent; present only when set. |
[ { "id": 9051, "account_id": 11, "campaign_id": 42, "contact_id": 880, "genre": "clicked", "metadata": { "ip_address": "203.0.113.7", "user_agent": "Mozilla/5.0" }, "created_at": "2026-05-30T10:01:08.000Z", "updated_at": "2026-05-30T10:01:08.000Z", "genre_display_name": "Clicked", "ip_address": "203.0.113.7", "user_agent": "Mozilla/5.0" }]Status codes
| Code | When |
|---|---|
| 200 | Events returned. |
| 404 | account_id does not belong to the token user. |
POST /api/v1/accounts/:account_id/events
Section titled “POST /api/v1/accounts/:account_id/events”Records a new event. The event’s account is set from the path account, and on save the model overrides it to match the campaign’s account. Auth: Bearer; role: read (any role).
Parameters
Body params are wrapped in an event object.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| account_id | path | string | yes | Account id (acct_… or integer). |
| event.campaign_id | body | integer | yes | Campaign for this event. Validated presence. |
| event.contact_id | body | integer | yes | Contact for this event. Validated presence. |
| event.genre | body | string | no | Event genre; defaults to sent. One of the genre enum values. Validated presence. |
| event.metadata | body | object | no | Arbitrary JSON hash (permitted as metadata: {}). Defaults to {}. |
| event.name | body | string | no | Accepted by strong params but not persisted (no name column). |
Request
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "event": { "campaign_id": 42, "contact_id": 880, "genre": "opened", "metadata": { "ip_address": "203.0.113.7", "user_agent": "Mozilla/5.0" } } }' \ https://platform.phishspot.com/api/v1/accounts/11/eventsResponse 201 Created — the created event object (same shape as the index item above).
{ "id": 9044, "account_id": 11, "campaign_id": 42, "contact_id": 880, "genre": "opened", "metadata": { "ip_address": "203.0.113.7", "user_agent": "Mozilla/5.0" }, "created_at": "2026-05-30T09:58:21.000Z", "updated_at": "2026-05-30T09:58:21.000Z", "genre_display_name": "Opened", "ip_address": "203.0.113.7", "user_agent": "Mozilla/5.0"}Status codes
| Code | When |
|---|---|
| 201 | Event created. |
| 404 | account_id does not belong to the token user. |
| 422 | Validation failed (missing campaign_id/contact_id/genre, or an invalid genre). Body: { "errors": { … } }. |
GET /api/v1/events/:id
Section titled “GET /api/v1/events/:id”Fetches a single event. Auth: Bearer; role: read (any role).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Event id (evt_… or integer). |
Request
curl -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/events/9044Response 200 OK — single event object (same fields as the index item above).
{ "id": 9044, "account_id": 11, "campaign_id": 42, "contact_id": 880, "genre": "opened", "metadata": { "ip_address": "203.0.113.7" }, "created_at": "2026-05-30T09:58:21.000Z", "updated_at": "2026-05-30T09:58:21.000Z", "genre_display_name": "Opened", "ip_address": "203.0.113.7"}Status codes
| Code | When |
|---|---|
| 200 | Event returned. |
| 404 | No event with that id in an account the token user belongs to. Body: { "error": "Event not found" }. |
PATCH /api/v1/events/:id
Section titled “PATCH /api/v1/events/:id”Updates an event’s genre or metadata. Auth: Bearer; role: read (any role).
Parameters
Body params are wrapped in an event object; send only the fields you want to change.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Event id (evt_… or integer). |
| event.genre | body | string | no | New genre (one of the genre enum values; presence still enforced). |
| event.metadata | body | object | no | Replacement metadata hash. |
| event.campaign_id | body | integer | no | Reassign campaign (presence enforced). |
| event.contact_id | body | integer | no | Reassign contact (presence enforced). |
| event.name | body | string | no | Accepted but not persisted. |
Request
curl -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "event": { "metadata": { "note": "manual correction" } } }' \ https://platform.phishspot.com/api/v1/events/9044Response 200 OK — the updated event object (same shape as show).
Status codes
| Code | When |
|---|---|
| 200 | Event updated. |
| 404 | No event with that id in an account the token user belongs to. Body: { "error": "Event not found" }. |
| 422 | Validation failed (e.g. invalid/blank genre). Body: { "errors": { … } }. |
DELETE /api/v1/events/:id
Section titled “DELETE /api/v1/events/:id”Permanently deletes an event. Auth: Bearer; role: read (any role).
No parameters beyond the bearer token and the path id.
Request
curl -X DELETE -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/events/9044Response 204 No Content — empty body on success.
Status codes
| Code | When |
|---|---|
| 204 | Event deleted. |
| 404 | No event with that id in an account the token user belongs to. Body: { "error": "Event not found" }. |
GET /api/v1/accounts/:account_id/results
Section titled “GET /api/v1/accounts/:account_id/results”Lists the account’s e-learning results newest-first. There are no query filters on this endpoint. Auth: Bearer; role: read (any role).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| account_id | path | string | yes | Account id (acct_… or integer). |
Request
curl -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/accounts/11/resultsResponse 200 OK — JSON array of result objects.
| Field | Type | Description |
|---|---|---|
| id | integer | Result id. |
| block_id | integer | Course block this result is for. |
| contact_id | integer | Contact who produced the result. |
| account_id | integer | Owning account. |
| metadata | object | Free-form JSON (e.g. answer, correct, score, time_spent, completed). Defaults to {}. |
| created_at | string | ISO 8601 timestamp. |
| updated_at | string | ISO 8601 timestamp. |
| block | object | null | Present when the block loads: { id, name }. |
| contact | object | null | Present when the contact loads: { id, email, first_name, last_name, full_name }. |
[ { "id": 7100, "block_id": 320, "contact_id": 880, "account_id": 11, "metadata": { "answer": "B", "correct": true, "score": 100, "completed": true }, "created_at": "2026-05-31T14:20:00.000Z", "updated_at": "2026-05-31T14:20:00.000Z", "block": { "id": 320, "name": "Spot the Lookalike Domain" }, "contact": { "id": 880, "email": "jan.kowalski@acme.test", "first_name": "Jan", "last_name": "Kowalski", "full_name": "Jan Kowalski" } }]Status codes
| Code | When |
|---|---|
| 200 | Results returned. |
| 403 | Token user is not authorized to view the account (account.show? denied). |
| 404 | account_id does not belong to the token user. |
POST /api/v1/accounts/:account_id/results
Section titled “POST /api/v1/accounts/:account_id/results”Records a contact’s result for a course block. The account is derived from the block automatically. Auth: Bearer; role: read (any role).
Parameters
Body params are wrapped in a result object.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| account_id | path | string | yes | Account id (acct_… or integer). |
| result.block_id | body | integer | yes | Course block this result is for. Validated presence. |
| result.contact_id | body | integer | yes | Contact producing the result. Validated presence. |
| result.metadata | body | object | no | JSON hash of answer/score/completion data. Defaults to {}. Permitted as a scalar param (:metadata), so send it as a JSON object value. |
| result.name | body | string | no | Accepted by strong params but not persisted (no name column). |
| result.state | body | string | no | Accepted by strong params but not persisted (Result has no state column/enum). |
Request
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "result": { "block_id": 320, "contact_id": 880, "metadata": { "answer": "B", "correct": true, "score": 100, "completed": true } } }' \ https://platform.phishspot.com/api/v1/accounts/11/resultsResponse 201 Created — the created result object (same shape as the index item above).
{ "id": 7150, "block_id": 320, "contact_id": 880, "account_id": 11, "metadata": { "answer": "B", "correct": true, "score": 100, "completed": true }, "created_at": "2026-06-02T08:30:00.000Z", "updated_at": "2026-06-02T08:30:00.000Z", "block": { "id": 320, "name": "Spot the Lookalike Domain" }, "contact": { "id": 880, "email": "jan.kowalski@acme.test", "first_name": "Jan", "last_name": "Kowalski", "full_name": "Jan Kowalski" }}Status codes
| Code | When |
|---|---|
| 201 | Result created. |
| 404 | account_id does not belong to the token user. |
| 422 | Validation failed (missing block_id/contact_id). Body: { "errors": { … } }. |
GET /api/v1/results/:id
Section titled “GET /api/v1/results/:id”Fetches a single result. Auth: Bearer; role: read (any role).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Result id (res_… or integer). |
Request
curl -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/results/7100Response 200 OK — single result object (same fields as the index item above).
{ "id": 7100, "block_id": 320, "contact_id": 880, "account_id": 11, "metadata": { "answer": "B", "correct": true, "score": 100 }, "created_at": "2026-05-31T14:20:00.000Z", "updated_at": "2026-05-31T14:20:00.000Z", "block": { "id": 320, "name": "Spot the Lookalike Domain" }, "contact": { "id": 880, "email": "jan.kowalski@acme.test", "first_name": "Jan", "last_name": "Kowalski", "full_name": "Jan Kowalski" }}Status codes
| Code | When |
|---|---|
| 200 | Result returned. |
| 404 | No result with that id in an account the token user belongs to. |
PATCH /api/v1/results/:id
Section titled “PATCH /api/v1/results/:id”Updates a result, typically to revise its metadata (answer, score, completion). Auth: Bearer; role: read (any role).
Parameters
Body params are wrapped in a result object; send only the fields you want to change.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Result id (res_… or integer). |
| result.metadata | body | object | no | Replacement metadata hash. |
| result.block_id | body | integer | no | Reassign block (presence enforced). |
| result.contact_id | body | integer | no | Reassign contact (presence enforced). |
| result.name | body | string | no | Accepted but not persisted. |
| result.state | body | string | no | Accepted but not persisted. |
Request
curl -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "result": { "metadata": { "answer": "C", "correct": false, "score": 0 } } }' \ https://platform.phishspot.com/api/v1/results/7100Response 200 OK — the updated result object (same shape as show).
Status codes
| Code | When |
|---|---|
| 200 | Result updated. |
| 404 | No result with that id in an account the token user belongs to. |
| 422 | Validation failed (e.g. block_id/contact_id blanked). Body: { "errors": { … } }. |
DELETE /api/v1/results/:id
Section titled “DELETE /api/v1/results/:id”Permanently deletes a result. Auth: Bearer; role: read (any role).
No parameters beyond the bearer token and the path id.
Request
curl -X DELETE -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/results/7100Response 204 No Content — empty body on success.
Status codes
| Code | When |
|---|---|
| 204 | Result deleted. |
| 404 | No result with that id in an account the token user belongs to. |
27.8 Account trends
Section titled “27.8 Account trends”Aggregated phishing-susceptibility analytics for an account: one data point per delivered campaign over a date range, plus a roll-up summary. Backed by Campaigns::TrendService, which only counts campaigns whose state is in_progress or done (the delivered scope) created within the selected range.
GET /accounts/:account_id/trends
Section titled “GET /accounts/:account_id/trends”Returns susceptibility metrics for an account’s delivered campaigns, ordered oldest-first, together with a summary (campaign count, average click rate, trend direction, and the most-clicked recipient group). Use it to power a trend dashboard or to track whether employees are getting better or worse at spotting phishing over time. Auth: Bearer; role: read (any role — any member of the account).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| account_id | path | string | yes | Account id (acct_… or integer). Must be an account the token’s user belongs to. |
| start_date | query | string | no | Start of a custom range, YYYY-MM-DD. Parsed to the beginning of that day. If present (with or without end_date), the custom range takes precedence over range. When omitted but end_date is given, defaults to 1 year ago. |
| end_date | query | string | no | End of a custom range, YYYY-MM-DD. Parsed to the end of that day. When omitted but start_date is given, defaults to now. |
| range | query | string | no | Preset range, used only when neither start_date nor end_date is present. One of 30d, 90d, 6m, all. Any other/absent value (including the default) yields the last 1 year. all spans from the Unix epoch to now. |
Request
curl -X GET -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ "https://platform.phishspot.com/api/v1/accounts/11/trends?start_date=2026-01-01&end_date=2026-06-01"Response 200 OK — an object with a campaigns array (one entry per delivered campaign in range) and a summary object.
| Field | Type | Description |
|---|---|---|
| campaigns | array | Delivered campaigns in the range, ordered by created_at ascending. Each element is a data point (fields below). Empty array if none. |
| campaigns[].id | integer | Campaign id (raw integer). |
| campaigns[].name | string | Campaign name. |
| campaigns[].date | string | Date the campaign ran, YYYY-MM-DD (ISO 8601 date). Uses scheduled_at if set, otherwise created_at. |
| campaigns[].open_rate | number (float) | Percent of recipients who opened the email (0.0 when no data). |
| campaigns[].click_rate | number (float) | Percent of recipients who clicked a link (0.0 when no data). |
| campaigns[].submit_rate | number (float) | Percent of recipients who submitted data on the landing page (0.0 when no data). |
| campaigns[].total_sent | integer | Number of recipients the campaign was sent to. |
| summary | object | Roll-up across the campaigns above (fields below). |
| summary.total_campaigns | integer | Count of delivered campaigns in the range (0 when none). |
| summary.avg_click_rate | number (float) | Mean of the per-campaign click_rate values, rounded to 1 decimal (0.0 when none). |
| summary.trend_direction | string | One of improving, worsening, stable, or neutral. Compares the average click rate of the most recent up-to-3 campaigns against the earliest up-to-3; neutral when fewer than 2 campaigns, stable when the change is within ±1.0 percentage point. |
| summary.most_vulnerable_group | string | null | Name of the recipient group with the highest click-to-sent ratio across the range; null when no group data is available. |
{ "campaigns": [ { "id": 42, "name": "Q1 Invoice Lure", "date": "2026-01-14", "open_rate": 61.5, "click_rate": 23.1, "submit_rate": 7.7, "total_sent": 130 }, { "id": 57, "name": "Password Expiry Notice", "date": "2026-04-02", "open_rate": 54.0, "click_rate": 12.0, "submit_rate": 4.0, "total_sent": 150 } ], "summary": { "total_campaigns": 2, "avg_click_rate": 17.6, "trend_direction": "improving", "most_vulnerable_group": "Finance" }}Status codes
| Code | When |
|---|---|
| 200 | Trend data returned (the campaigns array and summary are present even when there are no matching campaigns). |
| 404 | account_id does not belong to the token’s user ({"error":"Account not found"}). |
| 422 | start_date or end_date is not a parseable YYYY-MM-DD date ({"error":"Invalid date; use YYYY-MM-DD."}). |
27.9 Courses & blocks
Section titled “27.9 Courses & blocks”E-learning courses delivered to employees who fall for a phishing simulation. A course is an ordered collection of blocks (text, HTML, video, quiz, etc.). Courses are either owned by your account or global (curated, shared library). Blocks are nested under a course for listing/creation but addressable by their own shallow id for show/update/destroy.
GET /api/v1/accounts/:account_id/courses
Section titled “GET /api/v1/accounts/:account_id/courses”Lists every course available to the account — courses your account owns plus all global: true courses — ordered by name, with a lightweight block summary inlined. Auth: Bearer; role: read (any role).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| account_id | path | string | yes | Account id (acct_… or integer). Must be an account the token user belongs to. |
Request
curl -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/accounts/11/coursesResponse 200 OK — JSON array of course objects (see the course fields below).
| Field | Type | Description |
|---|---|---|
| id | integer | Course id. |
| account_id | integer | Owning account id. |
| name | string | Course name. |
| description | string | Course description. |
| global | boolean | true for shared/curated library courses, false for account-owned. |
| created_at | string | ISO 8601 timestamp. |
| updated_at | string | ISO 8601 timestamp. |
| blocks | array | Inlined block summaries (see fields below). Ordered as stored on the association. |
| blocks[].id | integer | Block id. |
| blocks[].name | string | Block name. |
| blocks[].order | integer | Zero-based position within the course. |
| blocks[].genre | string | Block genre (see enum under block endpoints). |
[ { "id": 7, "account_id": 11, "name": "Spotting Spoofed Senders", "description": "A short course on recognising display-name and domain spoofing.", "global": false, "created_at": "2026-05-01T09:12:00.000Z", "updated_at": "2026-05-14T16:40:11.000Z", "blocks": [ { "id": 31, "name": "Intro", "order": 0, "genre": "html" }, { "id": 32, "name": "Quick check", "order": 1, "genre": "quiz" } ] }]Status codes
| Code | When |
|---|---|
| 200 | Courses returned. |
| 404 | account_id is not an account the token user belongs to. |
POST /api/v1/accounts/:account_id/courses
Section titled “POST /api/v1/accounts/:account_id/courses”Creates a new account-owned course. The course is always created under the path account (global cannot be set — new courses are private). Both name and description are required by the model. Auth: Bearer; role: read (any role — all team members can create courses).
Parameters
Body params are wrapped in a course object.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| account_id | path | string | yes | Account id (acct_… or integer). |
| course | body | object | yes | Wrapper object holding the fields below. |
| course.name | body | string | yes | Course name. Presence-validated. |
| course.description | body | string | yes | Course description. Presence-validated. |
Request
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "course": { "name": "Invoice Fraud 101", "description": "Recognising fake supplier invoices." } }' \ https://platform.phishspot.com/api/v1/accounts/11/coursesResponse 201 Created — the created course, same shape as a list item (with an empty blocks array).
| Field | Type | Description |
|---|---|---|
| id | integer | New course id. |
| account_id | integer | Owning account id (the path account). |
| name | string | Course name. |
| description | string | Course description. |
| global | boolean | Always false for newly created courses. |
| created_at | string | ISO 8601 timestamp. |
| updated_at | string | ISO 8601 timestamp. |
| blocks | array | Empty on creation. |
{ "id": 19, "account_id": 11, "name": "Invoice Fraud 101", "description": "Recognising fake supplier invoices.", "global": false, "created_at": "2026-06-02T10:00:00.000Z", "updated_at": "2026-06-02T10:00:00.000Z", "blocks": []}Status codes
| Code | When |
|---|---|
| 201 | Course created. |
| 404 | account_id is not an account the token user belongs to. |
| 422 | Validation failed — e.g. name or description blank. Body: {"errors": {"name": ["can't be blank"]}}. |
GET /api/v1/courses/:id
Section titled “GET /api/v1/courses/:id”Fetches a single course by its shallow id. Resolvable for courses your account owns or any global course; a course belonging to another tenant returns 404. Auth: Bearer; role: read (any role).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Course id (course_… or integer). |
Request
curl -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/courses/7Response 200 OK — one course object, same fields as a list item (including the inlined blocks summary).
| Field | Type | Description |
|---|---|---|
| id | integer | Course id. |
| account_id | integer | Owning account id. |
| name | string | Course name. |
| description | string | Course description. |
| global | boolean | Whether the course is from the shared library. |
| created_at | string | ISO 8601 timestamp. |
| updated_at | string | ISO 8601 timestamp. |
| blocks | array | Inlined block summaries: id, name, order, genre. |
{ "id": 7, "account_id": 11, "name": "Spotting Spoofed Senders", "description": "A short course on recognising display-name and domain spoofing.", "global": false, "created_at": "2026-05-01T09:12:00.000Z", "updated_at": "2026-05-14T16:40:11.000Z", "blocks": [ { "id": 31, "name": "Intro", "order": 0, "genre": "html" }, { "id": 32, "name": "Quick check", "order": 1, "genre": "quiz" } ]}Status codes
| Code | When |
|---|---|
| 200 | Course returned. |
| 404 | No course with that id is owned by your account and it is not global. |
PATCH /api/v1/courses/:id
Section titled “PATCH /api/v1/courses/:id”Updates a course’s name and/or description. Auth: Bearer; role: read (any role), subject to the global-ownership and lock checks below.
Parameters
Body params are wrapped in a course object. Only name and description are permitted.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Course id (course_… or integer). |
| course | body | object | yes | Wrapper object holding the fields below. |
| course.name | body | string | no | New name. Cannot be blank if supplied (presence-validated). |
| course.description | body | string | no | New description. Cannot be blank if supplied (presence-validated). |
Request
curl -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "course": { "description": "Updated for the 2026 supplier-fraud wave." } }' \ https://platform.phishspot.com/api/v1/courses/7Response 200 OK — the updated course (same shape as GET /courses/:id).
{ "id": 7, "account_id": 11, "name": "Spotting Spoofed Senders", "description": "Updated for the 2026 supplier-fraud wave.", "global": false, "created_at": "2026-05-01T09:12:00.000Z", "updated_at": "2026-06-02T10:05:00.000Z", "blocks": [ { "id": 31, "name": "Intro", "order": 0, "genre": "html" } ]}Status codes
| Code | When |
|---|---|
| 200 | Course updated. |
| 403 | The course is global and not owned by your account, or it is locked by an in-progress/paused campaign. |
| 404 | Course not reachable by your account (not owned and not global). |
| 422 | Validation failed — e.g. name/description set to blank. Body: {"errors": {...}}. |
DELETE /api/v1/courses/:id
Section titled “DELETE /api/v1/courses/:id”Deletes a course and (via dependent: :destroy) all of its blocks. Auth: Bearer; role: read (any role), subject to the global-ownership and lock checks below.
Parameters
No parameters beyond the bearer token and the path id.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Course id (course_… or integer). |
Request
curl -X DELETE -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/courses/19Response 204 No Content — empty body on success.
Status codes
| Code | When |
|---|---|
| 204 | Course deleted. |
| 403 | The course is global and not owned by your account, or it is locked by an in-progress/paused campaign. |
| 404 | Course not reachable by your account. |
GET /api/v1/courses/:course_id/blocks
Section titled “GET /api/v1/courses/:course_id/blocks”Lists the blocks of a course, ordered by order (ascending), scoped through Pundit to blocks of accessible (owned or global) courses. Auth: Bearer; role: read (any role).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| course_id | path | string | yes | Course id (course_… or integer). Must be owned by your account or global. |
Request
curl -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/courses/7/blocksResponse 200 OK — JSON array of full block objects (fields below).
| Field | Type | Description |
|---|---|---|
| id | integer | Block id. |
| name | string | Block name. |
| course_id | integer | Parent course id. |
| order | integer | Zero-based position within the course. |
| genre | string | One of: text, html, image, video, quiz, interactive, code, file_download. |
| metadata | object | Free-form JSON. For quiz blocks this holds the question/answers payload. |
| created_at | string | ISO 8601 timestamp. |
| updated_at | string | ISO 8601 timestamp. |
| html_data | string | Rendered rich-text HTML. Present only when the block has ActionText content. |
| locked | boolean | true when an associated campaign is in progress (block cannot be updated/deleted). |
| quiz_question | string | Quiz blocks only. The parsed question text. |
| quiz_answers | array | Quiz blocks only. Array of answer hashes parsed from metadata. |
| url | string | Canonical API URL for this block (/api/v1/blocks/:id). |
[ { "id": 31, "name": "Intro", "course_id": 7, "order": 0, "genre": "html", "metadata": {}, "created_at": "2026-05-01T09:12:00.000Z", "updated_at": "2026-05-01T09:12:00.000Z", "html_data": "<div>Welcome to the course.</div>", "locked": false, "url": "https://platform.phishspot.com/api/v1/blocks/31" }, { "id": 32, "name": "Quick check", "course_id": 7, "order": 1, "genre": "quiz", "metadata": [ { "question_text": "Which sender is spoofed?" }, { "answer_text": "no-reply@paypa1.com", "right_answer": "on" }, { "answer_text": "no-reply@paypal.com" } ], "created_at": "2026-05-01T09:13:00.000Z", "updated_at": "2026-05-01T09:13:00.000Z", "locked": false, "quiz_question": "Which sender is spoofed?", "quiz_answers": [ { "answer_text": "no-reply@paypa1.com", "right_answer": "on" }, { "answer_text": "no-reply@paypal.com" } ], "url": "https://platform.phishspot.com/api/v1/blocks/32" }]Status codes
| Code | When |
|---|---|
| 200 | Blocks returned (empty array if the course has none). |
| 404 | course_id not reachable by your account (not owned and not global). |
POST /api/v1/courses/:course_id/blocks
Section titled “POST /api/v1/courses/:course_id/blocks”Adds a block to a course. The block’s account is set automatically from the course; if order is omitted it is auto-assigned to the end of the course. Content requirements vary by genre (see below). Auth: Bearer; role: read (any role), but you cannot add blocks to a global course you don’t own (403).
Parameters
Body params are wrapped in a block object.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| course_id | path | string | yes | Course id (course_… or integer). |
| block | body | object | yes | Wrapper object holding the fields below. |
| block.name | body | string | yes | Block name. Presence-validated. |
| block.genre | body | string | yes | One of text, html, image, video, quiz, interactive, code, file_download. Defaults to text if omitted. |
| block.body | body | string | conditional | Plain-text/markdown body. Required unless genre is quiz, html, video, or file_download. For video/file_download it serves as an optional description. |
| block.html_data | body | string | conditional | Rich HTML content (ActionText). For html/text blocks, supply either body or html_data. |
| block.metadata | body | object/array | conditional | Free-form JSON. Required for quiz blocks, where it carries the question and answers (see quiz constraints). |
| block.order | body | integer | no | Position within the course (integer ≥ 0). Auto-assigned to the end if omitted. |
| block.course_id | body | integer | no | Permitted but normally redundant with the path course_id. |
| block.video_file | body | file | conditional | Video attachment. Required for video blocks. Must be video/mp4 or video/webm, ≤ 300 MB, ≤ 1920×1080, ≤ 600 s, video codec h264/vp8/vp9, audio codec aac/opus. Blank string values are ignored. |
| block.document_file | body | file | conditional | Document attachment. Required for file_download blocks. Any format, ≤ 100 MB. Blank string values are ignored. |
Request
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "block": { "name": "Identify the phish", "genre": "quiz", "metadata": [ { "question_text": "Which sender is spoofed?" }, { "answer_text": "no-reply@paypa1.com", "right_answer": "on" }, { "answer_text": "no-reply@paypal.com" } ] } }' \ https://platform.phishspot.com/api/v1/courses/7/blocksResponse 201 Created — the created block (same shape as a list item).
{ "id": 33, "name": "Identify the phish", "course_id": 7, "order": 2, "genre": "quiz", "metadata": [ { "question_text": "Which sender is spoofed?" }, { "answer_text": "no-reply@paypa1.com", "right_answer": "on" }, { "answer_text": "no-reply@paypal.com" } ], "created_at": "2026-06-02T10:10:00.000Z", "updated_at": "2026-06-02T10:10:00.000Z", "locked": false, "quiz_question": "Which sender is spoofed?", "quiz_answers": [ { "answer_text": "no-reply@paypa1.com", "right_answer": "on" }, { "answer_text": "no-reply@paypal.com" } ], "url": "https://platform.phishspot.com/api/v1/blocks/33"}Status codes
| Code | When |
|---|---|
| 201 | Block created. |
| 403 | The parent course is global and not owned by your account. |
| 404 | course_id not reachable by your account. |
| 422 | Validation failed — missing name/genre, missing required content for the genre (body, html_data, metadata, video_file, or document_file), too few/too many quiz answers, no correct quiz answer, or an unacceptable video/document file. Body: {"errors": {...}}. |
GET /api/v1/blocks/:id
Section titled “GET /api/v1/blocks/:id”Fetches a single block by its shallow id, scoped to blocks of courses your account can reach (owned or global). Auth: Bearer; role: read (any role).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Block id (blk_… or integer). |
Request
curl -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/blocks/32Response 200 OK — one block object (full fields as in the blocks list).
| Field | Type | Description |
|---|---|---|
| id | integer | Block id. |
| name | string | Block name. |
| course_id | integer | Parent course id. |
| order | integer | Zero-based position. |
| genre | string | Block genre (see enum above). |
| metadata | object/array | Free-form JSON; quiz payload for quiz blocks. |
| created_at | string | ISO 8601 timestamp. |
| updated_at | string | ISO 8601 timestamp. |
| html_data | string | Rendered rich text. Present only when set. |
| locked | boolean | true when an associated campaign is in progress. |
| quiz_question | string | Quiz blocks only. |
| quiz_answers | array | Quiz blocks only. |
| url | string | Canonical API URL for this block. |
{ "id": 32, "name": "Quick check", "course_id": 7, "order": 1, "genre": "quiz", "metadata": [ { "question_text": "Which sender is spoofed?" }, { "answer_text": "no-reply@paypa1.com", "right_answer": "on" }, { "answer_text": "no-reply@paypal.com" } ], "created_at": "2026-05-01T09:13:00.000Z", "updated_at": "2026-05-01T09:13:00.000Z", "locked": false, "quiz_question": "Which sender is spoofed?", "quiz_answers": [ { "answer_text": "no-reply@paypa1.com", "right_answer": "on" }, { "answer_text": "no-reply@paypal.com" } ], "url": "https://platform.phishspot.com/api/v1/blocks/32"}Status codes
| Code | When |
|---|---|
| 200 | Block returned. |
| 404 | Block not reachable by your account (its course is neither owned nor global). |
PATCH /api/v1/blocks/:id
Section titled “PATCH /api/v1/blocks/:id”Updates a block. Same permitted fields and genre-specific content rules as create. Auth: Bearer; role: read (any role), but you cannot edit a block on a global course you don’t own (403), and a locked? block (campaign in progress) raises a not-destroyed error during the update.
Parameters
Body params are wrapped in a block object. Permitted keys: name, body, course_id, order, genre, metadata, html_data, video_file, document_file.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Block id (blk_… or integer). |
| block | body | object | yes | Wrapper object holding any of the permitted fields. |
| block.name | body | string | no | New name (presence-validated if supplied). |
| block.genre | body | string | no | Change genre; same enum as create. Changing genre may make other fields required. |
| block.body | body | string | no | Body text. Required unless genre is quiz/html/video/file_download. |
| block.html_data | body | string | no | Rich HTML content. |
| block.metadata | body | object/array | no | Free-form JSON; quiz payload (2–6 answers, ≥1 correct). |
| block.order | body | integer | no | New position (integer ≥ 0). |
| block.video_file | body | file | no | Replacement video (same constraints as create). Blank strings ignored. |
| block.document_file | body | file | no | Replacement document (≤ 100 MB). Blank strings ignored. |
Request
curl -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "block": { "name": "Identify the phishing sender", "order": 1 } }' \ https://platform.phishspot.com/api/v1/blocks/32Response 200 OK — the updated block (same shape as GET /blocks/:id).
{ "id": 32, "name": "Identify the phishing sender", "course_id": 7, "order": 1, "genre": "quiz", "metadata": [ { "question_text": "Which sender is spoofed?" }, { "answer_text": "no-reply@paypa1.com", "right_answer": "on" }, { "answer_text": "no-reply@paypal.com" } ], "created_at": "2026-05-01T09:13:00.000Z", "updated_at": "2026-06-02T10:15:00.000Z", "locked": false, "quiz_question": "Which sender is spoofed?", "quiz_answers": [ { "answer_text": "no-reply@paypa1.com", "right_answer": "on" }, { "answer_text": "no-reply@paypal.com" } ], "url": "https://platform.phishspot.com/api/v1/blocks/32"}Status codes
| Code | When |
|---|---|
| 200 | Block updated. |
| 403 | The block’s course is global and not owned by your account. |
| 404 | Block not reachable by your account. |
| 422 | Validation failed — blank name, missing genre-required content, invalid quiz answers, unacceptable file, or the block is locked by a campaign in progress. Body: {"errors": {...}}. |
DELETE /api/v1/blocks/:id
Section titled “DELETE /api/v1/blocks/:id”Deletes a block. The controller first checks whether the block is locked by an in-progress campaign and refuses with 422 if so. Auth: Bearer; role: read (any role), but you cannot delete a block on a global course you don’t own (403).
Parameters
No parameters beyond the bearer token and the path id.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Block id (blk_… or integer). |
Request
curl -X DELETE -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/blocks/33Response 204 No Content — empty body on success.
Status codes
| Code | When |
|---|---|
| 204 | Block deleted. |
| 403 | The block’s course is global and not owned by your account. |
| 404 | Block not reachable by your account. |
| 422 | The block is locked (a connected campaign is in progress). Body: {"errors": ["Cannot delete block because connected campaign is in progress"]}. |
27.10 Autopilots
Section titled “27.10 Autopilots”Autopilots are recurring, hands-off phishing programs: you describe an audience and a cadence, and the platform keeps generating and delivering campaigns automatically until you stop it. An autopilot is created as a draft, then driven through a small lifecycle (draft → running ⇄ paused → stopped) via the dedicated member actions below.
::: caution
Starting an autopilot activates a live, sending program — the platform will begin generating and delivering real phishing campaigns to the targeted members at the configured cadence. Treat POST /autopilots/:id/start as a go-live action, not a dry run.
:::
A few model facts referenced throughout this section:
- State (
state): one ofdraft,running,paused,stopped. - Intensity period (
intensity_period): one ofday,week,month,year. - Duration kind (
duration_kind):continuous(runs forever) oruntil_date(stops onends_on). - End action type (
end_action_type): one ofnothing,redirect_to_course,message_page,redirect_to_url— controls what a target sees after the simulation. - Daily-rate cap: the effective send rate is
intensity_count / period_in_daysand must not exceed 2 campaigns/day (day=1,week=7,month=30,year=365 days per period). Exceeding it fails validation onintensity_count. - Editable: an autopilot is editable unless it is
stopped. Oncestopped, only read and delete remain available.
GET /accounts/:account_id/autopilots
Section titled “GET /accounts/:account_id/autopilots”Lists every autopilot belonging to an account, newest first. Use it to render an autopilot dashboard or to find an autopilot’s id before acting on it. Auth: Bearer; role: read (any role — member, editor, or admin).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| account_id | path | string | yes | Account id (acct_… or integer). |
| state | query | string | no | Filter to a single state. One of draft, running, paused, stopped. Any other value returns 422. |
Request
curl -H "Authorization: Bearer $TOKEN" \ "https://platform.phishspot.com/api/v1/accounts/11/autopilots?state=running"Response 200 OK — a JSON array of autopilot objects (each identical to the single-autopilot response below).
| Field | Type | Description |
|---|---|---|
| id | integer | Autopilot id. |
| account_id | integer | Owning account id. |
| name | string | Display name. |
| state | string | draft | running | paused | stopped. |
| all_groups | boolean | Whether the autopilot targets all groups (vs. the listed groups). |
| intensity_count | integer | Campaigns per intensity_period. |
| intensity_period | string | day | week | month | year. |
| duration_kind | string | continuous | until_date. |
| ai_optimizer_enabled | boolean | Whether AI optimization is on. |
| auto_include_new_members | boolean | Whether new members are auto-enrolled. |
| language | string | Target language code (e.g. en, pl). |
| end_action_type | string | nothing | redirect_to_course | message_page | redirect_to_url. |
| end_action_url | string | null | Redirect URL (used when end_action_type is redirect_to_url). |
| created_at | string | ISO-8601 timestamp. |
| updated_at | string | ISO-8601 timestamp. |
| ends_on | string | null | ISO-8601 date the program stops (when duration_kind is until_date), else null. |
| started_at | string | null | ISO-8601 timestamp of first start, else null. |
| daily_rate | number | Effective campaigns/day, rounded to 2 decimals. |
| progress_percentage | integer | null | Integer % of expected campaigns delivered this period; null for draft/stopped. |
| course_id | integer | null | Linked e-learning course id, else null. |
| editable | boolean | false only when state is stopped. |
| groups | array | Targeted groups. Each: { "id": integer, "name": string }. |
| recent_campaigns | array | Up to 10 most recent generated campaigns, newest first. Each: { "id": integer, "name": string, "state": string }. |
[ { "id": 7, "account_id": 11, "name": "Finance team — quarterly drip", "state": "running", "all_groups": false, "intensity_count": 2, "intensity_period": "month", "duration_kind": "continuous", "ai_optimizer_enabled": true, "auto_include_new_members": true, "language": "en", "end_action_type": "redirect_to_course", "end_action_url": null, "created_at": "2026-05-01T09:00:00Z", "updated_at": "2026-06-01T12:30:00Z", "ends_on": null, "started_at": "2026-05-02T08:00:00Z", "daily_rate": 0.07, "progress_percentage": 88, "course_id": 14, "editable": true, "groups": [ { "id": 3, "name": "Finance" } ], "recent_campaigns": [ { "id": 102, "name": "Invoice approval — May", "state": "completed" } ] }]Status codes
| Code | When |
|---|---|
| 200 | List returned (possibly empty). |
| 404 | The account_id does not exist or the token’s user is not a member of it. |
| 422 | state query param is present but not one of draft, running, paused, stopped. |
POST /accounts/:account_id/autopilots
Section titled “POST /accounts/:account_id/autopilots”Creates a new autopilot in the draft state. Blank fields are prefilled from the account’s autopilot settings and account defaults (industry, language, end-action URL/HTML, default course), so a minimal body still produces a usable draft. The autopilot is not started — call start afterward to go live. Auth: Bearer; role: admin/editor.
The body is wrapped in an autopilot object.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| account_id | path | string | yes | Account id (acct_… or integer). |
| name | body | string | yes | Display name. Max 80 chars; must be unique within the account (case-insensitive). |
| all_groups | body | boolean | no | Target all groups. Defaults to true. |
| group_ids | body | array | no | Prefixed group ids (grp_…) to target. Unknown/foreign id → 422. Used when all_groups is false. |
| intensity_count | body | integer | no | Campaigns per period. Must be ≥ 1. Defaults to 1. The resulting daily rate (intensity_count / period_days) must be ≤ 2/day. |
| intensity_period | body | string | no | day | week | month | year. Defaults to month. |
| duration_kind | body | string | no | continuous | until_date. Defaults to continuous. |
| ends_on | body | string (date) | conditional | Required (and must be a future date) when duration_kind is until_date. |
| ai_optimizer_enabled | body | boolean | no | Defaults to true. |
| auto_include_new_members | body | boolean | no | Defaults to true. |
| language | body | string | no | Target language code (e.g. en, pl). Prefilled from account settings/locale if blank. |
| industry_code_id | body | integer | no | Industry code id. Prefilled from account settings if blank. |
| end_action_type | body | string | no | nothing | redirect_to_course | message_page | redirect_to_url. Defaults to message_page. |
| end_action_url | body | string | conditional | Required and must be http/https when end_action_type is redirect_to_url. |
| end_action_html | body | string | conditional | Required when end_action_type is message_page (prefilled from account defaults if blank). |
| course_id | body | string | conditional | Prefixed course id (course_…); must belong to the account or be a global course (else 422). Required when end_action_type is redirect_to_course. |
Request
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "autopilot": { "name": "Finance team — quarterly drip", "all_groups": false, "group_ids": ["grp_3a9k"], "intensity_count": 2, "intensity_period": "month", "duration_kind": "continuous", "end_action_type": "redirect_to_course", "course_id": "course_8h2d" } }' \ https://platform.phishspot.com/api/v1/accounts/11/autopilotsResponse 201 Created — the newly created autopilot, in the same shape as the list item above (state will be draft, started_at and progress_percentage null).
{ "id": 9, "account_id": 11, "name": "Finance team — quarterly drip", "state": "draft", "all_groups": false, "intensity_count": 2, "intensity_period": "month", "duration_kind": "continuous", "ai_optimizer_enabled": true, "auto_include_new_members": true, "language": "en", "end_action_type": "redirect_to_course", "end_action_url": null, "created_at": "2026-06-02T10:15:00Z", "updated_at": "2026-06-02T10:15:00Z", "ends_on": null, "started_at": null, "daily_rate": 0.07, "progress_percentage": null, "course_id": 14, "editable": true, "groups": [ { "id": 3, "name": "Finance" } ], "recent_campaigns": []}Status codes
| Code | When |
|---|---|
| 201 | Autopilot created. |
| 400 | The autopilot body wrapper is missing entirely. |
| 403 | Token’s user is a member (read-only) on the account — only admins/editors may create. |
| 404 | The account_id does not exist or the token’s user is not a member of it. |
| 422 | Validation failed — e.g. blank/duplicate/too-long name, daily rate above the 2/day cap, missing ends_on for until_date, missing end_action_url/end_action_html/course_id for the chosen end_action_type, or an unknown group_ids / course_id. |
GET /autopilots/:id
Section titled “GET /autopilots/:id”Fetches a single autopilot by its id. This is a shallow (non-nested) route — no account_id in the path; the account is inferred from the autopilot and the token’s membership is verified. Auth: Bearer; role: admin/editor.
No parameters beyond the bearer token.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Autopilot id (auto_… or integer). |
Request
curl -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/autopilots/9Response 200 OK — a single autopilot object (identical field set to the list item documented under GET …/autopilots).
{ "id": 9, "account_id": 11, "name": "Finance team — quarterly drip", "state": "running", "all_groups": false, "intensity_count": 2, "intensity_period": "month", "duration_kind": "continuous", "ai_optimizer_enabled": true, "auto_include_new_members": true, "language": "en", "end_action_type": "redirect_to_course", "end_action_url": null, "created_at": "2026-06-02T10:15:00Z", "updated_at": "2026-06-02T10:16:00Z", "ends_on": null, "started_at": "2026-06-02T10:16:00Z", "daily_rate": 0.07, "progress_percentage": 100, "course_id": 14, "editable": true, "groups": [ { "id": 3, "name": "Finance" } ], "recent_campaigns": []}Status codes
| Code | When |
|---|---|
| 200 | Autopilot returned. |
| 403 | Token’s user is a member (read-only) on the autopilot’s account — single-autopilot reads require admin/editor. |
| 404 | No autopilot with that id, or the token’s user has no active membership in its account. |
PATCH /autopilots/:id
Section titled “PATCH /autopilots/:id”Updates an autopilot’s configuration. The body is wrapped in an autopilot object and accepts the same fields as create. Passing group_ids replaces the full set of targeted groups. Auth: Bearer; role: admin/editor — and the autopilot must be editable (not stopped). Shallow route (no account_id).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Autopilot id (auto_… or integer). |
| name | body | string | no | Display name. Max 80 chars; unique per account (case-insensitive). |
| all_groups | body | boolean | no | Target all groups. |
| group_ids | body | array | no | Prefixed group ids (grp_…). When present, replaces the current group set; unknown/foreign id → 422. |
| intensity_count | body | integer | no | Campaigns per period (≥ 1; daily rate must stay ≤ 2/day). |
| intensity_period | body | string | no | day | week | month | year. |
| duration_kind | body | string | no | continuous | until_date. |
| ends_on | body | string (date) | conditional | Required future date when duration_kind is until_date. |
| ai_optimizer_enabled | body | boolean | no | Toggle AI optimization. |
| auto_include_new_members | body | boolean | no | Toggle auto-enrollment. |
| language | body | string | no | Target language code. |
| industry_code_id | body | integer | no | Industry code id. |
| end_action_type | body | string | no | nothing | redirect_to_course | message_page | redirect_to_url. |
| end_action_url | body | string | conditional | Required http/https URL when end_action_type is redirect_to_url. |
| end_action_html | body | string | conditional | Required when end_action_type is message_page. |
| course_id | body | string | conditional | Prefixed course id (course_…); must belong to the account or be global. Required when end_action_type is redirect_to_course. |
Request
curl -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "autopilot": { "intensity_count": 1, "intensity_period": "week" } }' \ https://platform.phishspot.com/api/v1/autopilots/9Response 200 OK — the updated autopilot object (same shape as GET /autopilots/:id).
{ "id": 9, "account_id": 11, "name": "Finance team — quarterly drip", "state": "running", "all_groups": false, "intensity_count": 1, "intensity_period": "week", "duration_kind": "continuous", "ai_optimizer_enabled": true, "auto_include_new_members": true, "language": "en", "end_action_type": "redirect_to_course", "end_action_url": null, "created_at": "2026-06-02T10:15:00Z", "updated_at": "2026-06-02T11:00:00Z", "ends_on": null, "started_at": "2026-06-02T10:16:00Z", "daily_rate": 0.14, "progress_percentage": 100, "course_id": 14, "editable": true, "groups": [ { "id": 3, "name": "Finance" } ], "recent_campaigns": []}Status codes
| Code | When |
|---|---|
| 200 | Autopilot updated. |
| 400 | The autopilot body wrapper is missing entirely. |
| 403 | Token’s user is a member (read-only), or the autopilot is stopped (stopped autopilots are read-only — delete only). |
| 404 | No autopilot with that id, or the token’s user has no active membership in its account. |
| 422 | Validation failed — same triggers as create (name, daily-rate cap, ends_on, end-action requirements, unknown group_ids/course_id). |
DELETE /autopilots/:id
Section titled “DELETE /autopilots/:id”Permanently deletes an autopilot and its group links; generated campaigns are detached (not deleted). A running autopilot cannot be deleted — stop it first. Auth: Bearer; role: admin/editor. Shallow route (no account_id).
No parameters beyond the bearer token.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Autopilot id (auto_… or integer). |
Request
curl -X DELETE -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/autopilots/9Response 204 No Content — empty body on success.
Status codes
| Code | When |
|---|---|
| 204 | Autopilot deleted. |
| 403 | Token’s user is a member (read-only) on the autopilot’s account. |
| 404 | No autopilot with that id, or the token’s user has no active membership in its account. |
| 422 | The autopilot is running — stop it before deleting. (A paused or draft autopilot can be deleted directly.) |
POST /autopilots/:id/start
Section titled “POST /autopilots/:id/start”Activates the autopilot: sets state to running (stamping started_at on first start) so the platform begins generating and delivering campaigns at the configured cadence. Use to go live, or to resume a paused autopilot. Auth: Bearer; role: admin/editor — and the autopilot must be editable (not stopped). Shallow route.
::: caution This is a live action: a successful call begins sending real simulated phishing email to targeted members. :::
No parameters beyond the bearer token.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Autopilot id (auto_… or integer). |
Request
curl -X POST -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/autopilots/9/startResponse 200 OK — the autopilot object with state: "running" (same shape as GET /autopilots/:id).
{ "id": 9, "name": "Finance team — quarterly drip", "state": "running", "started_at": "2026-06-02T10:16:00Z", "editable": true, "progress_percentage": 100, "daily_rate": 0.07}Status codes
| Code | When |
|---|---|
| 200 | Autopilot started/resumed; now running. |
| 403 | Token’s user is a member (read-only), or the autopilot is stopped (a stopped autopilot cannot be restarted). |
| 404 | No autopilot with that id, or the token’s user has no active membership in its account. |
POST /autopilots/:id/pause
Section titled “POST /autopilots/:id/pause”Pauses a running autopilot: sets state to paused so no new campaigns are generated. Resume later with start. Auth: Bearer; role: admin/editor — and the autopilot must be editable (not stopped). Shallow route.
No parameters beyond the bearer token.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Autopilot id (auto_… or integer). |
Request
curl -X POST -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/autopilots/9/pauseResponse 200 OK — the autopilot object with state: "paused" (same shape as GET /autopilots/:id).
{ "id": 9, "name": "Finance team — quarterly drip", "state": "paused", "started_at": "2026-06-02T10:16:00Z", "editable": true, "progress_percentage": 92}Status codes
| Code | When |
|---|---|
| 200 | Autopilot paused. |
| 403 | Token’s user is a member (read-only), or the autopilot is stopped. |
| 404 | No autopilot with that id, or the token’s user has no active membership in its account. |
POST /autopilots/:id/stop
Section titled “POST /autopilots/:id/stop”Stops the autopilot permanently: sets state to stopped. A stopped autopilot becomes read-only — it can no longer be updated, started, paused, or stopped again, only viewed or deleted. Auth: Bearer; role: admin/editor — and the autopilot must still be editable (not already stopped). Shallow route.
No parameters beyond the bearer token.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Autopilot id (auto_… or integer). |
Request
curl -X POST -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/autopilots/9/stopResponse 200 OK — the autopilot object with state: "stopped" and editable: false (same shape as GET /autopilots/:id).
{ "id": 9, "name": "Finance team — quarterly drip", "state": "stopped", "started_at": "2026-06-02T10:16:00Z", "editable": false, "progress_percentage": null}Status codes
| Code | When |
|---|---|
| 200 | Autopilot stopped. |
| 403 | Token’s user is a member (read-only), or the autopilot is already stopped. |
| 404 | No autopilot with that id, or the token’s user has no active membership in its account. |
27.11 Sending domains
Section titled “27.11 Sending domains”Two related resources control which domains a campaign can send from:
- Platform domains (
pdm_…) are the attacker/landing domains PhishSpot operates. Most are platform-owned (“public” or assigned “private” domains); customers can also bring their own (BYOD) viaprovision_byod. Writing platform-domain records directly (POST/PATCH/DELETE /platform_domains) is reserved for admin users; provisioning a BYOD domain is open to any account member. - Secured domains (
sdm_…) are DNS-ownership proofs. A customer adds a domain they control, drops a TXT record, and verifies it — this is how PhishSpot confirms a sender is allowed to send “from” that domain.
GET /api/v1/platform_domains
Section titled “GET /api/v1/platform_domains”Lists every platform domain visible to the calling token’s account — all operational public domains plus any operational private domains assigned to the account. Results are ordered by name. Auth: Bearer; role: read (any role).
No parameters beyond the bearer token.
Request
curl -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/platform_domainsResponse 200 OK — a JSON array of platform-domain objects (each object uses the same fields as the show endpoint below).
| Field | Type | Description |
|---|---|---|
| id | integer | Numeric id. (Use the pdm_… prefixed id in URLs.) |
| name | string | Domain name (e.g. officelogin.in). |
| public | boolean | Legacy public flag from the column. |
| boolean | Whether this is the platform’s mail domain. | |
| state | string | Lifecycle state. One of pending, checking, confirmed, purchasing, purchased, configuring_dns, dns_pending, configuring_postal, active, failed. |
| expires_on | string|null | ISO8601 registration expiry, or null. |
| metadata | object | Free-form JSON (Cloudflare/Postal provisioning details, diagnostics, etc.). |
| created_at | string | ISO8601 timestamp. |
| updated_at | string | ISO8601 timestamp. |
| active | boolean | True when state == "active". |
| byod | boolean | True for customer “bring your own domain” records. |
| sending_blocked | boolean | True when the domain is active but blocked from starting new sends. |
| nameservers | array | Cloudflare nameservers assigned to this domain’s zone (empty unless captured). |
| cloudflare_error | string|null | Set if the Cloudflare zone could not be created/read. |
[ { "id": 42, "name": "officelogin.in", "public": true, "mail": false, "state": "active", "expires_on": "2027-03-01T00:00:00.000Z", "metadata": {}, "created_at": "2026-01-10T09:00:00.000Z", "updated_at": "2026-05-30T14:22:00.000Z", "active": true, "byod": false, "sending_blocked": false, "nameservers": [], "cloudflare_error": null }]Status codes
| Code | When |
|---|---|
| 200 | Domains returned (possibly an empty array). |
GET /api/v1/platform_domains/:id
Section titled “GET /api/v1/platform_domains/:id”Fetches a single platform domain by id. Useful for inspecting state, BYOD nameservers, and sending_blocked before selecting it for a campaign. Auth: Bearer; role: read (any role).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Platform-domain id (pdm_… or integer). |
Request
curl -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/platform_domains/pdm_42Response 200 OK — a single platform-domain object (same fields as the list endpoint above).
{ "id": 42, "name": "officelogin.in", "public": true, "mail": false, "state": "active", "expires_on": "2027-03-01T00:00:00.000Z", "metadata": {}, "created_at": "2026-01-10T09:00:00.000Z", "updated_at": "2026-05-30T14:22:00.000Z", "active": true, "byod": false, "sending_blocked": false, "nameservers": [], "cloudflare_error": null}Status codes
| Code | When |
|---|---|
| 200 | Domain found. |
| 404 | No platform domain with that id. |
POST /api/v1/platform_domains
Section titled “POST /api/v1/platform_domains”Creates a platform-owned domain record directly. This is an administrative operation for managing the platform’s own domain pool — customers should use provision_byod instead. Auth: Bearer; role: admin.
Parameters — wrapped in a platform_domain object.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| platform_domain.name | body | string | yes | Domain name. Lowercased/trimmed; must be a valid domain (3–253 chars, at least one dot, only a-z 0-9 . -, no leading/trailing dot or hyphen, no consecutive ../--, labels ≤63 chars). Must be globally unique (case-insensitive). |
| platform_domain.public | body | boolean | no | Legacy public flag. |
| platform_domain.metadata | body | object | no | Free-form JSON metadata. |
| platform_domain.expires_on | body | string | no | ISO8601 registration expiry. |
`state`, `genre`, and `source` are not settable through the API; a record created here defaults to `state: "active"`, `genre: "public"`, `source: "manual"`.Request
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "platform_domain": { "name": "secure-portal.co", "public": true } }' \ https://platform.phishspot.com/api/v1/platform_domainsResponse 201 Created — the created platform-domain object (same fields as the show endpoint).
{ "id": 77, "name": "secure-portal.co", "public": true, "mail": false, "state": "active", "expires_on": null, "metadata": {}, "created_at": "2026-06-02T10:00:00.000Z", "updated_at": "2026-06-02T10:00:00.000Z", "active": true, "byod": false, "sending_blocked": false, "nameservers": [], "cloudflare_error": null}Status codes
| Code | When |
|---|---|
| 201 | Domain created. |
| 403 | Caller is not an admin. |
| 422 | Validation failed (e.g. blank/duplicate/invalid name). Body: { "errors": { "name": ["..."] } }. |
PATCH /api/v1/platform_domains/:id
Section titled “PATCH /api/v1/platform_domains/:id”Updates a platform-owned domain record. Auth: Bearer; role: admin.
Parameters — same wrapped platform_domain body as create; all fields optional.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Platform-domain id (pdm_… or integer). |
| platform_domain.name | body | string | no | New domain name (same validation rules as create). |
| platform_domain.public | body | boolean | no | Legacy public flag. |
| platform_domain.metadata | body | object | no | Free-form JSON metadata. |
| platform_domain.expires_on | body | string | no | ISO8601 registration expiry. |
Renaming or otherwise updating a domain that has an in-progress (locked) campaign is rejected at the model level (`ActiveRecord::RecordNotDestroyed`), which surfaces as a 500-level error rather than 422. Avoid editing domains attached to running campaigns.Request
curl -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "platform_domain": { "expires_on": "2028-01-01T00:00:00Z" } }' \ https://platform.phishspot.com/api/v1/platform_domains/pdm_42Response 200 OK — the updated platform-domain object (same fields as the show endpoint).
Status codes
| Code | When |
|---|---|
| 200 | Domain updated. |
| 403 | Caller is not an admin. |
| 404 | No platform domain with that id. |
| 422 | Validation failed. Body: { "errors": { ... } }. |
DELETE /api/v1/platform_domains/:id
Section titled “DELETE /api/v1/platform_domains/:id”Deletes a platform-owned domain. A domain can only be deleted if it has no blocking associations: platform-owned domains must have no campaigns and no assigned accounts; BYOD domains must have no active (in-progress/paused) campaigns. Auth: Bearer; role: admin.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Platform-domain id (pdm_… or integer). |
Request
curl -X DELETE -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/platform_domains/pdm_42Response 204 No Content — empty body on success.
Status codes
| Code | When |
|---|---|
| 204 | Domain deleted. |
| 403 | Caller is not an admin. |
| 404 | No platform domain with that id. |
| 422 | Domain still has active campaigns (or other blocking associations). Body: { "error": "Cannot delete platform domain with active campaigns" }. |
POST /api/v1/platform_domains/:id/check
Section titled “POST /api/v1/platform_domains/:id/check”Re-checks the BYOD provisioning/health status of a domain the calling account owns, then returns the (reloaded) domain. Use this to poll a BYOD domain after delegating nameservers: an active domain runs a health check, a still-provisioning domain refreshes its setup status. Transient refresh failures are swallowed so polling never errors — you just get the last persisted state. Auth: Bearer; role: read (any role), but the token’s user must belong to the domain’s owner account.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Platform-domain id (pdm_… or integer). Must be a BYOD domain owned by an account the caller belongs to. |
Request
curl -X POST -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/platform_domains/pdm_88/checkResponse 200 OK — the refreshed platform-domain object (same fields as the show endpoint). Watch state, active, nameservers, sending_blocked, and cloudflare_error to track progress.
{ "id": 88, "name": "mail.acme-customer.com", "public": false, "mail": false, "state": "dns_pending", "expires_on": null, "metadata": { "cloudflare_nameservers": ["kara.ns.cloudflare.com", "rob.ns.cloudflare.com"] }, "created_at": "2026-06-01T08:00:00.000Z", "updated_at": "2026-06-02T09:30:00.000Z", "active": false, "byod": true, "sending_blocked": false, "nameservers": ["kara.ns.cloudflare.com", "rob.ns.cloudflare.com"], "cloudflare_error": null}Status codes
| Code | When |
|---|---|
| 200 | Status refreshed and domain returned. |
| 404 | No such domain, or the domain has no owner account / the caller does not belong to its owner account. |
POST /api/v1/accounts/:account_id/platform_domains/provision_byod
Section titled “POST /api/v1/accounts/:account_id/platform_domains/provision_byod”Provisions a customer “bring your own domain” (BYOD) sending domain for the account. Creates a private, BYOD platform domain in a provisioning state and returns the Cloudflare nameservers the domain owner must set at their registrar; delegation and verification then complete asynchronously (poll with POST /platform_domains/:id/check). Idempotent — re-provisioning a domain the account already owns just re-surfaces its nameservers. Auth: Bearer; role: any role, but the caller must belong to account_id.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| account_id | path | string | yes | Account id (acct_… or integer) the caller belongs to. |
| domain_name | body | string | yes | Domain to provision (e.g. mail.acme-customer.com). Lowercased/trimmed server-side. Not wrapped in any object — sent as a top-level key. |
Request
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "domain_name": "mail.acme-customer.com" }' \ https://platform.phishspot.com/api/v1/accounts/11/platform_domains/provision_byodResponse 201 Created — the provisioned platform-domain object plus provisioning guidance. All platform-domain fields from the show endpoint, plus:
| Field | Type | Description |
|---|---|---|
| nameservers | array | Cloudflare nameservers the domain owner must set at their registrar. (Also present in the base object; repeated at the top level for convenience.) |
| next_step | string | Human-readable instruction describing the registrar change and the check endpoint to poll. |
{ "id": 88, "name": "mail.acme-customer.com", "public": null, "mail": false, "state": "dns_pending", "expires_on": null, "metadata": { "cloudflare_nameservers": ["kara.ns.cloudflare.com", "rob.ns.cloudflare.com"] }, "created_at": "2026-06-02T09:00:00.000Z", "updated_at": "2026-06-02T09:00:00.000Z", "active": false, "byod": true, "sending_blocked": false, "nameservers": ["kara.ns.cloudflare.com", "rob.ns.cloudflare.com"], "cloudflare_error": null, "next_step": "At the registrar for mail.acme-customer.com, replace the nameservers with the ones above, then poll POST /api/v1/platform_domains/pdm_88/check."}Status codes
| Code | When |
|---|---|
| 201 | Domain provisioned (or re-surfaced if already owned by this account). |
| 403 | Caller does not belong to account_id (Pundit show? denied). |
| 404 | account_id not found among the caller’s accounts. Body: { "error": "Account not found" }. |
| 422 | Provisioning rejected. Body: { "error": "<message>" }, one of: blank name ("Domain name is required."), already registered by another account ("That domain is already registered in PhishSpot by another account."), or invalid domain ("That domain name is invalid."). |
GET /api/v1/accounts/:account_id/secured_domains
Section titled “GET /api/v1/accounts/:account_id/secured_domains”Lists the secured (ownership-verified) domains for an account, newest first. Optionally filter by verification state. Auth: Bearer; role: any role, but the caller must belong to account_id.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| account_id | path | string | yes | Account id (acct_… or integer) the caller belongs to. |
| state | query | string | no | Filter by verification state. One of pending, verified, failed. Omit for all. |
Request
curl -H "Authorization: Bearer $TOKEN" \ "https://platform.phishspot.com/api/v1/accounts/11/secured_domains?state=verified"Response 200 OK — a JSON array of secured-domain objects.
| Field | Type | Description |
|---|---|---|
| id | integer | Numeric id. (Use the sdm_… prefixed id in URLs.) |
| account_id | integer | Owning account id. |
| domain | string | The domain being verified (lowercased). |
| state | string | Verification state: pending, verified, or failed. |
| verification_attempts | integer | Number of DNS verification attempts made. |
| verified_at | string|null | ISO8601 timestamp of successful verification, or null. |
| created_at | string | ISO8601 timestamp. |
| updated_at | string | ISO8601 timestamp. |
| dns_record | object | The TXT record the owner must publish to prove ownership. |
| dns_record.type | string | Always "TXT". |
| dns_record.name | string | Record host, e.g. _phishspot-verify.example.com. |
| dns_record.value | string | Record value, e.g. phishspot-verify=<64-hex-token>. |
[ { "id": 5, "account_id": 11, "domain": "acme-customer.com", "state": "verified", "verification_attempts": 2, "verified_at": "2026-05-20T11:00:00.000Z", "created_at": "2026-05-19T16:30:00.000Z", "updated_at": "2026-05-20T11:00:00.000Z", "dns_record": { "type": "TXT", "name": "_phishspot-verify.acme-customer.com", "value": "phishspot-verify=3f9a...c2" } }]Status codes
| Code | When |
|---|---|
| 200 | Domains returned (possibly an empty array). |
| 403 | Caller does not belong to account_id. |
| 404 | account_id not found among the caller’s accounts. Body: { "error": "Account not found" }. |
POST /api/v1/accounts/:account_id/secured_domains
Section titled “POST /api/v1/accounts/:account_id/secured_domains”Adds a domain the customer controls and returns the TXT record to publish for ownership verification. Public email-provider domains (e.g. gmail.com) are rejected. Auth: Bearer; role: any role, but the caller must belong to account_id.
Parameters — wrapped in a secured_domain object.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| account_id | path | string | yes | Account id (acct_… or integer) the caller belongs to. |
| secured_domain.domain | body | string | yes | Domain to verify (e.g. acme-customer.com). Lowercased/trimmed; must match the standard domain format; must be unique per account (case-insensitive); cannot be a blocked public-email-provider domain. |
Request
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "secured_domain": { "domain": "acme-customer.com" } }' \ https://platform.phishspot.com/api/v1/accounts/11/secured_domainsResponse 201 Created — the created secured-domain object (same fields as the list endpoint), with state: "pending" and a freshly generated dns_record to publish.
{ "id": 6, "account_id": 11, "domain": "acme-customer.com", "state": "pending", "verification_attempts": 0, "verified_at": null, "created_at": "2026-06-02T10:00:00.000Z", "updated_at": "2026-06-02T10:00:00.000Z", "dns_record": { "type": "TXT", "name": "_phishspot-verify.acme-customer.com", "value": "phishspot-verify=3f9a...c2" }}Status codes
| Code | When |
|---|---|
| 201 | Domain added; publish the returned TXT record, then call verify_dns. |
| 403 | Caller does not belong to account_id. |
| 404 | account_id not found among the caller’s accounts. Body: { "error": "Account not found" }. |
| 422 | Validation failed — blank/invalid format, duplicate for this account, or a blocked public-email-provider domain. Body: { "errors": { "domain": ["..."] } }. |
GET /api/v1/secured_domains/:id
Section titled “GET /api/v1/secured_domains/:id”Fetches a single secured domain by id, including the TXT record needed for verification. Shallow route (not nested under account) — the caller must belong to the owning account. Auth: Bearer; role: any role (account member).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Secured-domain id (sdm_… or integer). |
Request
curl -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/secured_domains/sdm_6Response 200 OK — a single secured-domain object (same fields as the list endpoint above).
Status codes
| Code | When |
|---|---|
| 200 | Domain found. |
| 403 | Pundit denied (should not normally occur — the policy permits any member). |
| 404 | No such domain, or the caller does not belong to its owning account. |
DELETE /api/v1/secured_domains/:id
Section titled “DELETE /api/v1/secured_domains/:id”Removes a secured domain. Blocked while any active (in-progress/paused) campaign sends from an email address on that domain. Shallow route — the caller must belong to the owning account. Auth: Bearer; role: any role (account member).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Secured-domain id (sdm_… or integer). |
Request
curl -X DELETE -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/secured_domains/sdm_6Response 204 No Content — empty body on success.
Status codes
| Code | When |
|---|---|
| 204 | Domain deleted. |
| 403 | Pundit denied (should not normally occur). |
| 404 | No such domain, or the caller does not belong to its owning account. |
| 422 | Domain is verified and in use by active campaigns. Body: { "error": "Cannot delete secured domain <domain> while it is used by active campaigns: <campaign names>" }. |
POST /api/v1/secured_domains/:id/verify_dns
Section titled “POST /api/v1/secured_domains/:id/verify_dns”Runs DNS verification: looks up the expected TXT record for the domain and, if found, marks the domain verified. Call this after publishing the TXT record returned at creation. Returns the (reloaded) domain so you can read the resulting state. Shallow route — the caller must belong to the owning account. Auth: Bearer; role: any role (account member).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Secured-domain id (sdm_… or integer). |
Request
curl -X POST -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/secured_domains/sdm_6/verify_dnsResponse 200 OK — the refreshed secured-domain object (same fields as the list endpoint). On success state becomes "verified" and verified_at is set; otherwise it stays pending/failed and verification_attempts increments.
{ "id": 6, "account_id": 11, "domain": "acme-customer.com", "state": "verified", "verification_attempts": 1, "verified_at": "2026-06-02T10:05:00.000Z", "created_at": "2026-06-02T10:00:00.000Z", "updated_at": "2026-06-02T10:05:00.000Z", "dns_record": { "type": "TXT", "name": "_phishspot-verify.acme-customer.com", "value": "phishspot-verify=3f9a...c2" }}Status codes
| Code | When |
|---|---|
| 200 | Verification ran; check state in the response. |
| 403 | Pundit denied (should not normally occur). |
| 404 | No such domain, or the caller does not belong to its owning account. |
27.12 Reported messages
Section titled “27.12 Reported messages”Reported messages are suspicious emails that employees flagged — either forwarded into the account’s report inbox (source: inbound_webhook) or submitted through the Outlook add-in (source: outlook_addin). The read endpoints below return metadata only (sender, subject, received-at, source, reporter) — never the message body, headers, or attachments. They are scoped to accounts the calling token’s user belongs to.
GET /accounts/:account_id/reported_messages
Section titled “GET /accounts/:account_id/reported_messages”Lists reported messages for one account, newest first (ordered by received_at descending). Use it to feed a triage queue or to sync reports into your SOC tooling. Auth: Bearer; role: read (any role).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| account_id | path | string | yes | Account id (acct_… or integer). Must be an account the token’s user belongs to. |
| source | query | string | no | Filter by intake source. One of inbound_webhook, outlook_addin. An unknown value returns 422. Omit to return all sources. |
| limit | query | integer | no | Page size. Defaults to 50; clamped to the range 1–500 (values <1 or 0 fall back to 50, values >500 are capped at 500). |
| page | query | integer | no | 1-based page number. Defaults to 1; values below 1 are treated as 1. Offset is computed as (page - 1) * limit. |
Request
curl -H "Authorization: Bearer $TOKEN" \ "https://platform.phishspot.com/api/v1/accounts/11/reported_messages?source=outlook_addin&limit=25&page=1"Response 200 OK — a JSON array of reported-message metadata objects (no envelope). Each element has these fields:
| Field | Type | Description |
|---|---|---|
| id | integer | Reported-message id. |
| account_id | integer | Owning account id. |
| from_email | string | Sender address of the reported email. |
| from_name | string | null | Sender display name, if captured. |
| subject | string | null | Subject line of the reported email. |
| message_id | string | null | Original RFC Message-ID, if captured. |
| source | string | Intake source: inbound_webhook or outlook_addin. |
| received_at | string (ISO 8601) | When the original email was received. |
| created_at | string (ISO 8601) | When the report was ingested into PhishSpot. |
| from_domain | string | Lower-cased domain portion of from_email (text after @). |
| reporter_contact_email | string | null | Email of the account Contact who reported it (matched on from_email); null when no matching Contact exists. |
[ { "id": 4821, "account_id": 11, "from_email": "billing@suspicious-invoice.example", "from_name": "Accounts Payable", "subject": "Overdue invoice — action required", "message_id": "<CADnf9x1@mail.suspicious-invoice.example>", "source": "outlook_addin", "received_at": "2026-05-28T09:14:00.000Z", "created_at": "2026-05-28T09:15:32.000Z", "from_domain": "suspicious-invoice.example", "reporter_contact_email": "jane.doe@acme.test" }]Status codes
| Code | When |
|---|---|
| 200 | Reports returned (array may be empty). |
| 403 | The token’s user is authorized but Pundit denies AccountPolicy#show? for this account. |
| 404 | account_id is not an account the token’s user belongs to (returns { "error": "Account not found" }). |
| 422 | source is present but not one of the valid sources (returns { "error": "Unknown source …; valid sources: inbound_webhook, outlook_addin." }). |
GET /reported_messages/:id
Section titled “GET /reported_messages/:id”Fetches the metadata for a single reported message. This is a shallow route — it takes the report id directly, not an account_id path segment. Account isolation is enforced server-side: the lookup is scoped to the token user’s accounts, so requesting a report in another account returns 404. Auth: Bearer; role: read (any role).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Reported-message id (rep_… or integer). |
No parameters beyond the bearer token and the path id.
Request
curl -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/reported_messages/rep_4821Response 200 OK — a single reported-message metadata object with the same fields as one element of the index array:
| Field | Type | Description |
|---|---|---|
| id | integer | Reported-message id. |
| account_id | integer | Owning account id. |
| from_email | string | Sender address of the reported email. |
| from_name | string | null | Sender display name, if captured. |
| subject | string | null | Subject line of the reported email. |
| message_id | string | null | Original RFC Message-ID, if captured. |
| source | string | Intake source: inbound_webhook or outlook_addin. |
| received_at | string (ISO 8601) | When the original email was received. |
| created_at | string (ISO 8601) | When the report was ingested into PhishSpot. |
| from_domain | string | Lower-cased domain portion of from_email. |
| reporter_contact_email | string | null | Email of the matching account Contact, or null. |
{ "id": 4821, "account_id": 11, "from_email": "billing@suspicious-invoice.example", "from_name": "Accounts Payable", "subject": "Overdue invoice — action required", "message_id": "<CADnf9x1@mail.suspicious-invoice.example>", "source": "outlook_addin", "received_at": "2026-05-28T09:14:00.000Z", "created_at": "2026-05-28T09:15:32.000Z", "from_domain": "suspicious-invoice.example", "reporter_contact_email": "jane.doe@acme.test"}Status codes
| Code | When |
|---|---|
| 200 | The report was found and returned. |
| 404 | No report with that id exists within the token user’s accounts (returns { "error": "Resource not found" }). |
POST /accounts/:account_id/reported_messages
Section titled “POST /accounts/:account_id/reported_messages”Add-in only. Ingests a newly reported message from the Outlook add-in. This endpoint does not use a normal API bearer token or Pundit — it requires an add-in capability token (reported_messages:create) whose pinned account_id matches the :account_id in the URL. Standard integrations do not call this; reads use the two endpoints above. The body is wrapped in a reported_message object (permitted keys: from_email, from_name, subject, plain_body, html_body, received_at, message_id, headers, plus an attachments array) and a successful call returns 201 Created with { "id": "rep_…", "url": "…" }. A capability/account mismatch returns 403; validation failures return 422.
Status codes
| Code | When |
|---|---|
| 201 | The report was created. |
| 403 | The token lacks the reported_messages:create capability, or its pinned account_id does not match the URL. |
| 404 | account_id does not resolve to an existing account (returns { "error": "Account not found" }). |
| 422 | The ingest service rejected the payload (returns { "error": "…" }). |
27.13 Media library
Section titled “27.13 Media library”The media library stores hosted image and CSS files for an account. Upload a file once, then reference its returned url inside campaign email HTML, landing pages, and templates. Always embed the hosted url — email clients (Gmail, Outlook) strip inline data: URIs, so base64-embedded images will not render.
Allowed content types: image/png, image/jpg, image/jpeg, image/gif, image/svg+xml, and text/css. Maximum file size is 5 MB. Every media item must have a unique (case-insensitive) name within its account.
All media endpoints are usable by any member of the account (no admin/editor gate). The collection endpoints (index, create) are nested under an account; the single-item endpoints (show, update, destroy) are shallow (/media/:id) and resolve only media belonging to one of the token user’s accounts — anything else returns 404.
GET /accounts/:account_id/media
Section titled “GET /accounts/:account_id/media”Lists all media items for the account, newest first. Use it to find a file’s hosted url before embedding it in a campaign. Auth: Bearer; role: read (any role).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| account_id | path | string | yes | Account id (acct_… or integer). |
Request
curl -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/accounts/11/mediaResponse 200 OK — a JSON array of media objects (each is the shape below).
| Field | Type | Description |
|---|---|---|
| id | integer | Media item id. |
| account_id | integer | Owning account id. |
| name | string | Display name (unique per account, case-insensitive). |
| created_at | string | ISO 8601 timestamp. |
| updated_at | string | ISO 8601 timestamp. |
| url | string|null | Hosted file URL (relative path); embed this in HTML. null if no file is attached. |
| filename | string|null | Original uploaded filename. |
| content_type | string|null | MIME type, e.g. image/png. |
[ { "id": 42, "account_id": 11, "name": "phishing-logo", "created_at": "2026-06-02T10:15:00.000Z", "updated_at": "2026-06-02T10:15:00.000Z", "url": "/rails/active_storage/blobs/redirect/eyJfcmFpbHMi.../phishing-logo.png", "filename": "phishing-logo.png", "content_type": "image/png" }]Status codes
| Code | When |
|---|---|
| 200 | Media list returned. |
| 404 | The account_id is not one of the token user’s accounts. |
POST /accounts/:account_id/media
Section titled “POST /accounts/:account_id/media”Uploads a new file to the account’s media library. This request is multipart/form-data, not JSON — the file is sent as a form field, not a base64 string in a JSON body. Auth: Bearer; role: any account member.
Parameters (form fields, wrapped in a medium[...] object)
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| account_id | path | string | yes | Account id (acct_… or integer). |
| medium[name] | body | string | yes | Display name; must be unique (case-insensitive) within the account. |
| medium[attachment] | body | file | yes | The file to upload. Must be one of image/png, image/jpg, image/jpeg, image/gif, image/svg+xml, text/css, and ≤ 5 MB. |
Request
Note the -F flags (multipart). Do not set Content-Type: application/json; let curl set the multipart boundary. The file is read from disk with @/path.
curl -X POST -H "Authorization: Bearer $TOKEN" \ -F "medium[name]=phishing-logo" \ -F "medium[attachment]=@./phishing-logo.png;type=image/png" \ https://platform.phishspot.com/api/v1/accounts/11/mediaResponse 201 Created — the created media object (same shape as the list item above).
| Field | Type | Description |
|---|---|---|
| id | integer | New media item id. |
| account_id | integer | Owning account id. |
| name | string | Display name. |
| created_at | string | ISO 8601 timestamp. |
| updated_at | string | ISO 8601 timestamp. |
| url | string | Hosted file URL — embed this in campaign HTML. |
| filename | string | Stored filename. |
| content_type | string | MIME type of the uploaded file. |
{ "id": 42, "account_id": 11, "name": "phishing-logo", "created_at": "2026-06-02T10:15:00.000Z", "updated_at": "2026-06-02T10:15:00.000Z", "url": "/rails/active_storage/blobs/redirect/eyJfcmFpbHMi.../phishing-logo.png", "filename": "phishing-logo.png", "content_type": "image/png"}Status codes
| Code | When |
|---|---|
| 201 | Media created. |
| 404 | The account_id is not one of the token user’s accounts. |
| 422 | Validation failed — missing/blank name, duplicate name in the account, missing attachment, disallowed content type, or file over 5 MB. Body: { "errors": { … } }. |
Example 422 body (missing attachment):
{ "errors": { "attachment": ["can't be blank"] } }GET /media/:id
Section titled “GET /media/:id”Returns a single media item by id. Resolves only media belonging to one of the token user’s accounts. Auth: Bearer; role: read (any role).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Media item id (med_… or integer). |
No parameters beyond the bearer token.
Request
curl -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/media/42Response 200 OK — the media object (same fields as the POST response above).
{ "id": 42, "account_id": 11, "name": "phishing-logo", "created_at": "2026-06-02T10:15:00.000Z", "updated_at": "2026-06-02T10:15:00.000Z", "url": "/rails/active_storage/blobs/redirect/eyJfcmFpbHMi.../phishing-logo.png", "filename": "phishing-logo.png", "content_type": "image/png"}Status codes
| Code | When |
|---|---|
| 200 | Media item returned. |
| 404 | No media item with that id belongs to one of the token user’s accounts. |
PATCH /media/:id
Section titled “PATCH /media/:id”Updates a media item. In practice only name is meaningful; you can also re-upload a file by sending a new attachment (multipart). Auth: Bearer; role: any account member.
Parameters (wrapped in a medium[...] object)
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Media item id (med_… or integer). |
| medium[name] | body | string | no | New display name; must stay unique (case-insensitive) within the account. |
| medium[attachment] | body | file | no | Replacement file (send as multipart). Same content-type and 5 MB limits as create. |
Request
A name-only change can be sent as JSON:
curl -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "medium": { "name": "phishing-logo-v2" } }' \ https://platform.phishspot.com/api/v1/media/42To replace the file, send multipart instead (-F "medium[attachment]=@./new.png;type=image/png").
Response 200 OK — the updated media object (same fields as show).
{ "id": 42, "account_id": 11, "name": "phishing-logo-v2", "created_at": "2026-06-02T10:15:00.000Z", "updated_at": "2026-06-02T11:02:00.000Z", "url": "/rails/active_storage/blobs/redirect/eyJfcmFpbHMi.../phishing-logo.png", "filename": "phishing-logo.png", "content_type": "image/png"}Status codes
| Code | When |
|---|---|
| 200 | Media updated. |
| 404 | No media item with that id belongs to one of the token user’s accounts. |
| 422 | Validation failed — blank name, duplicate name, or (on file replacement) disallowed content type / over 5 MB. Body: { "errors": { … } }. |
DELETE /media/:id
Section titled “DELETE /media/:id”Permanently deletes a media item and its attached file. Any campaign HTML still referencing the file’s url will break, so remove references first. Auth: Bearer; role: any account member.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Media item id (med_… or integer). |
No parameters beyond the bearer token.
Request
curl -X DELETE -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/media/42Response 204 No Content — empty body.
Status codes
| Code | When |
|---|---|
| 204 | Media deleted. |
| 404 | No media item with that id belongs to one of the token user’s accounts. |
27.14 Webhooks
Section titled “27.14 Webhooks”Manage outbound webhook subscriptions and inspect the events PhishSpot has generated for an account. An endpoint is a URL you register to receive signed HTTP POSTs; an event is an immutable record of something that happened (a campaign was created, a contact was deleted, etc.) that is fanned out to every enabled endpoint subscribed to its type. For the delivery mechanics — retry schedule, the X-Webhook-Signature HMAC header, and how to verify it with the signing_secret — see Webhook delivery & signatures.
Available event_type_ids values. An endpoint subscribes by listing one or more of these strings. The same strings appear as the event_type on emitted events:
| Event type | Fires when |
|---|---|
campaign.created | A campaign is created. |
campaign.updated | A campaign is updated. |
campaign.deleted | A campaign is deleted. |
contact.created | A contact is added. |
contact.updated | A contact is updated. |
contact.deleted | A contact is removed. |
deliverable.created | A campaign deliverable (one recipient’s send) is created. |
deliverable.updated | A deliverable changes state (sent, opened, clicked, etc.). |
spam_whitelist.updated | The account’s spam/allow-list snapshot changes. |
GET /accounts/:account_id/webhooks/endpoints
Section titled “GET /accounts/:account_id/webhooks/endpoints”Lists every webhook endpoint registered on the account, newest first. Use this to render a management UI or reconcile your local config. Auth: Bearer; role: read (any role).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| account_id | path | string | yes | Account id (acct_… or integer). |
Request
curl -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/accounts/11/webhooks/endpointsResponse 200 OK — a JSON array of endpoint objects. The signing_secret is omitted from this list view (it is only ever returned by the single-endpoint views).
| Field | Type | Description |
|---|---|---|
| id | integer | Endpoint id. The path/show segment also accepts the whep_… prefixed form. |
| account_id | integer | Owning account id. |
| name | string | Human label for the endpoint. |
| url | string | Destination URL that receives POSTs. |
| event_type_ids | array of string | Subscribed event types (see table above). |
| enabled | boolean | Whether deliveries are currently being sent. |
| api_version | integer | Payload schema version (currently 1). |
| created_at | string | ISO 8601 timestamp. |
| updated_at | string | ISO 8601 timestamp. |
| total_deliveries | integer | Count of all delivery records for this endpoint. |
| successful_deliveries | integer | Count of deliveries in delivered status. |
| failed_deliveries | integer | Count of deliveries in failed status. |
[ { "id": 42, "account_id": 11, "name": "Production listener", "url": "https://hooks.example.com/phishspot", "event_type_ids": ["campaign.created", "deliverable.updated"], "enabled": true, "api_version": 1, "created_at": "2026-05-30T09:14:22Z", "updated_at": "2026-06-01T12:03:10Z", "total_deliveries": 128, "successful_deliveries": 121, "failed_deliveries": 7 }]Status codes
| Code | When |
|---|---|
| 200 | Endpoints returned (empty array if none). |
| 403 | Caller is not a member of account_id. |
| 404 | account_id does not exist or caller is not a member. |
POST /accounts/:account_id/webhooks/endpoints
Section titled “POST /accounts/:account_id/webhooks/endpoints”Registers a new webhook endpoint. The response includes the signing_secret exactly once — store it immediately, as it is needed to verify delivery signatures and is never shown in full again except on show/update/toggle of that same endpoint. Auth: Bearer; role: read (any role — membership in the account is sufficient).
Parameters
Body params are wrapped in a webhook_endpoint object.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| account_id | path | string | yes | Account id (acct_… or integer). |
| webhook_endpoint.name | body | string | yes | Human label. Presence-validated. |
| webhook_endpoint.url | body | string | yes | Destination URL. Must be a valid http/https URL and pass the safety checks above. |
| webhook_endpoint.event_type_ids | body | array of string | yes | One or more event types to subscribe to (see table). Presence-validated — at least one required. |
| webhook_endpoint.enabled | body | boolean | no | Whether to start enabled. Defaults to true. |
Request
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "webhook_endpoint": { "name": "Production listener", "url": "https://hooks.example.com/phishspot", "event_type_ids": ["campaign.created", "deliverable.updated"], "enabled": false } }' \ https://platform.phishspot.com/api/v1/accounts/11/webhooks/endpointsResponse 201 Created — the created endpoint, including the one-time signing_secret. Fields are the same as the list view plus signing_secret:
| Field | Type | Description |
|---|---|---|
| id | integer | Endpoint id. |
| account_id | integer | Owning account id. |
| name | string | Human label. |
| url | string | Destination URL (trimmed/normalized). |
| event_type_ids | array of string | Subscribed event types. |
| enabled | boolean | Whether deliveries are active. |
| api_version | integer | Payload schema version (1). |
| created_at | string | ISO 8601 timestamp. |
| updated_at | string | ISO 8601 timestamp. |
| signing_secret | string | 64-char hex HMAC secret. Returned here — save it now. |
| total_deliveries | integer | 0 for a new endpoint. |
| successful_deliveries | integer | 0 for a new endpoint. |
| failed_deliveries | integer | 0 for a new endpoint. |
{ "id": 42, "account_id": 11, "name": "Production listener", "url": "https://hooks.example.com/phishspot", "event_type_ids": ["campaign.created", "deliverable.updated"], "enabled": false, "api_version": 1, "created_at": "2026-06-02T10:00:00Z", "updated_at": "2026-06-02T10:00:00Z", "signing_secret": "9f2c1e7b4a6d8f0c3e5a7b9d1f3c5e7a9b1d3f5c7e9a1b3d5f7c9e1a3b5d7f9c", "total_deliveries": 0, "successful_deliveries": 0, "failed_deliveries": 0}Status codes
| Code | When |
|---|---|
| 201 | Endpoint created. |
| 403 | Caller is not a member of account_id. |
| 404 | account_id does not exist or caller is not a member. |
| 422 | Validation failed — missing name/url/event_type_ids, a malformed URL, or a URL that targets localhost / a private IP / a *.phishspot.com host. |
GET /webhooks/endpoints/:id
Section titled “GET /webhooks/endpoints/:id”Fetches a single endpoint, including its full signing_secret and lifetime delivery statistics. Use this to re-read the secret if you lost it, or to monitor delivery health. Auth: Bearer; role: read (any role — must be a member of the owning account).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Endpoint id (whep_… or integer). |
No parameters beyond the bearer token and path id.
Request
curl -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/webhooks/endpoints/whep_8xk2p9Response 200 OK — one endpoint object with the same fields as the create response (includes signing_secret).
{ "id": 42, "account_id": 11, "name": "Production listener", "url": "https://hooks.example.com/phishspot", "event_type_ids": ["campaign.created", "deliverable.updated"], "enabled": true, "api_version": 1, "created_at": "2026-05-30T09:14:22Z", "updated_at": "2026-06-01T12:03:10Z", "signing_secret": "9f2c1e7b4a6d8f0c3e5a7b9d1f3c5e7a9b1d3f5c7e9a1b3d5f7c9e1a3b5d7f9c", "total_deliveries": 128, "successful_deliveries": 121, "failed_deliveries": 7}Status codes
| Code | When |
|---|---|
| 200 | Endpoint returned. |
| 403 | Caller is not a member of the account that owns the endpoint. |
| 404 | No endpoint with that id. |
PATCH /webhooks/endpoints/:id
Section titled “PATCH /webhooks/endpoints/:id”Updates an endpoint’s name, URL, subscribed event types, or enabled flag. The signing_secret is not regenerated and cannot be changed through this call. Auth: Bearer; role: read (any role — must be a member of the owning account).
Parameters
Body params are wrapped in a webhook_endpoint object; send only the fields you want to change.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Endpoint id (whep_… or integer). |
| webhook_endpoint.name | body | string | no | New label. Cannot be blanked (presence-validated). |
| webhook_endpoint.url | body | string | no | New destination URL. Re-validated for safety (same rules as create). |
| webhook_endpoint.event_type_ids | body | array of string | no | Replacement set of subscribed event types. Cannot be emptied. |
| webhook_endpoint.enabled | body | boolean | no | Enable/disable. |
Request
curl -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "webhook_endpoint": { "event_type_ids": ["campaign.created", "campaign.updated", "campaign.deleted"] } }' \ https://platform.phishspot.com/api/v1/webhooks/endpoints/whep_8xk2p9Response 200 OK — the updated endpoint object (same fields as show, including signing_secret).
{ "id": 42, "account_id": 11, "name": "Production listener", "url": "https://hooks.example.com/phishspot", "event_type_ids": ["campaign.created", "campaign.updated", "campaign.deleted"], "enabled": true, "api_version": 1, "created_at": "2026-05-30T09:14:22Z", "updated_at": "2026-06-02T11:20:45Z", "signing_secret": "9f2c1e7b4a6d8f0c3e5a7b9d1f3c5e7a9b1d3f5c7e9a1b3d5f7c9e1a3b5d7f9c", "total_deliveries": 128, "successful_deliveries": 121, "failed_deliveries": 7}Status codes
| Code | When |
|---|---|
| 200 | Endpoint updated. |
| 403 | Caller is not a member of the owning account. |
| 404 | No endpoint with that id. |
| 422 | Validation failed — blank name, empty event_type_ids, or an invalid/unsafe url. |
DELETE /webhooks/endpoints/:id
Section titled “DELETE /webhooks/endpoints/:id”Permanently deletes an endpoint and all of its delivery records. Events themselves are not deleted. Auth: Bearer; role: read (any role — must be a member of the owning account).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Endpoint id (whep_… or integer). |
No parameters beyond the bearer token and path id.
Request
curl -X DELETE -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/webhooks/endpoints/whep_8xk2p9Response 204 No Content — empty body on success.
Status codes
| Code | When |
|---|---|
| 204 | Endpoint deleted. |
| 403 | Caller is not a member of the owning account. |
| 404 | No endpoint with that id. |
POST /webhooks/endpoints/:id/toggle
Section titled “POST /webhooks/endpoints/:id/toggle”Flips the endpoint’s enabled flag — enables a disabled endpoint or disables an enabled one. Use this to pause/resume deliveries without deleting the endpoint or losing its secret. Auth: Bearer; role: read (any role — must be a member of the owning account).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Endpoint id (whep_… or integer). |
No parameters beyond the bearer token and path id — the new state is derived from the current one.
Request
curl -X POST -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/webhooks/endpoints/whep_8xk2p9/toggleResponse 200 OK — the endpoint with its flipped enabled value (same fields as show, including signing_secret).
{ "id": 42, "account_id": 11, "name": "Production listener", "url": "https://hooks.example.com/phishspot", "event_type_ids": ["campaign.created", "deliverable.updated"], "enabled": false, "api_version": 1, "created_at": "2026-05-30T09:14:22Z", "updated_at": "2026-06-02T11:30:00Z", "signing_secret": "9f2c1e7b4a6d8f0c3e5a7b9d1f3c5e7a9b1d3f5c7e9a1b3d5f7c9e1a3b5d7f9c", "total_deliveries": 128, "successful_deliveries": 121, "failed_deliveries": 7}Status codes
| Code | When |
|---|---|
| 200 | State flipped; updated endpoint returned. |
| 403 | Caller is not a member of the owning account. |
| 404 | No endpoint with that id. |
GET /accounts/:account_id/webhooks/events
Section titled “GET /accounts/:account_id/webhooks/events”Lists the events generated for the account, newest first, paginated. Each event records what happened and bundles per-event delivery counts. Use this to audit what PhishSpot tried to send, independent of any one endpoint. Auth: Bearer; role: read (any role).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| account_id | path | string | yes | Account id (acct_… or integer). |
| event_type | query | string | no | Filter to a single event type (e.g. deliverable.updated). Omit for all types. |
| page | query | integer | no | Page number. Defaults to 1. |
| per_page | query | integer | no | Items per page. Defaults to 50. |
Request
curl -H "Authorization: Bearer $TOKEN" \ "https://platform.phishspot.com/api/v1/accounts/11/webhooks/events?event_type=deliverable.updated&page=1&per_page=50"Response 200 OK — a JSON array of event objects.
| Field | Type | Description |
|---|---|---|
| id | integer | Event id. The show segment also accepts the prefixed/raw form. |
| account_id | integer | Owning account id. |
| subject_id | integer | Id of the record the event is about (campaign, contact, deliverable, …). |
| subject_type | string | Class name of the subject (e.g. Campaign, Contact, Deliverable). |
| event_type | string | One of the event types in the table above. |
| api_version | integer | Payload schema version (1). |
| uuid | string | Stable unique id for this event (also used as payload.id). |
| created_at | string | ISO 8601 timestamp. |
| updated_at | string | ISO 8601 timestamp. |
| data | object | Event-specific data describing the change (shape varies by event_type). |
| payload | object | The exact JSON body delivered to endpoints (see below). |
| deliveries | object | Per-event delivery counts. |
| deliveries.total | integer | All delivery records for this event. |
| deliveries.delivered | integer | Deliveries in delivered status. |
| deliveries.failed | integer | Deliveries in failed status. |
| deliveries.pending | integer | Deliveries in pending status. |
The payload object is what receivers actually get, with this shape: id (the event uuid), type (the event_type), created_at (ISO 8601), data (same as the top-level data), and api_version.
[ { "id": 9001, "account_id": 11, "subject_id": 305, "subject_type": "Deliverable", "event_type": "deliverable.updated", "api_version": 1, "uuid": "3f1c8a02-7d4e-4b9a-9c1e-2a6b5d8f0e11", "created_at": "2026-06-02T09:58:12Z", "updated_at": "2026-06-02T09:58:12Z", "data": { "deliverable_id": "dlv_4k2m", "state": "clicked" }, "payload": { "id": "3f1c8a02-7d4e-4b9a-9c1e-2a6b5d8f0e11", "type": "deliverable.updated", "created_at": "2026-06-02T09:58:12Z", "data": { "deliverable_id": "dlv_4k2m", "state": "clicked" }, "api_version": 1 }, "deliveries": { "total": 2, "delivered": 1, "failed": 0, "pending": 1 } }]Status codes
| Code | When |
|---|---|
| 200 | Events returned (empty array if none match). |
| 403 | Caller is not a member of account_id. |
| 404 | account_id does not exist or caller is not a member. |
GET /webhooks/events/:id
Section titled “GET /webhooks/events/:id”Fetches a single event by id, including its full data, the delivered payload, and delivery counts. Use this to inspect exactly what was sent for one occurrence. Auth: Bearer; role: read (any role — must be a member of the owning account).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
| id | path | string | yes | Event id (prefixed or raw integer). |
No parameters beyond the bearer token and path id.
Request
curl -H "Authorization: Bearer $TOKEN" \ https://platform.phishspot.com/api/v1/webhooks/events/9001Response 200 OK — one event object, identical in shape to a single element of the index array above.
{ "id": 9001, "account_id": 11, "subject_id": 305, "subject_type": "Deliverable", "event_type": "deliverable.updated", "api_version": 1, "uuid": "3f1c8a02-7d4e-4b9a-9c1e-2a6b5d8f0e11", "created_at": "2026-06-02T09:58:12Z", "updated_at": "2026-06-02T09:58:12Z", "data": { "deliverable_id": "dlv_4k2m", "state": "clicked" }, "payload": { "id": "3f1c8a02-7d4e-4b9a-9c1e-2a6b5d8f0e11", "type": "deliverable.updated", "created_at": "2026-06-02T09:58:12Z", "data": { "deliverable_id": "dlv_4k2m", "state": "clicked" }, "api_version": 1 }, "deliveries": { "total": 2, "delivered": 1, "failed": 0, "pending": 1 }}Status codes
| Code | When |
|---|---|
| 200 | Event returned. |
| 403 | Caller is not a member of the account that owns the event. |
| 404 | No event with that id. |
27.15 Outlook Add-in version (public)
Section titled “27.15 Outlook Add-in version (public)”GET /outlook/version
Section titled “GET /outlook/version”Returns the current Outlook add-in release metadata. No authentication required. Cached ~5 minutes.
Parameters: none.
curl https://platform.phishspot.com/api/v1/outlook/versionResponse 200 OK
| Field | Type | Description |
|---|---|---|
latest | string | Latest add-in version. |
min_supported | string | Oldest version still allowed to run. |
bundle_filename | string | Sideload bundle filename. |
bundle_sha256 | string | SHA-256 of the bundle. |
{ "latest": "1.1.0", "min_supported": "1.0.0", "bundle_filename": "phishspot-outlook-sideload-v1.1.0.zip", "bundle_sha256": "…" }Useful for inventory tooling that verifies which add-in version your fleet should run. See Chapter 20.
27.16 Spam-whitelist download (separate token system)
Section titled “27.16 Spam-whitelist download (separate token system)”The mail-admin self-serve URL from Chapter 22 uses a different scheme — a 64-character token embedded in the path, no Authorization header:
GET /integrations/spam/:token/:format
Section titled “GET /integrations/spam/:token/:format”| Name | In | Type | Required | Description |
|---|---|---|---|---|
token | path | string (64 hex) | yes | The whitelist token from the Integrations panel. |
format | path | enum | no | One of txt (default), json, csv, md, microsoft365, google-workspace, mimecast, proofpoint, postfix, spamassassin. |
Possession of the URL is the only credential — treat it as a password and rotate it from Account settings → Integrations → Spam Filter Whitelist if it leaks.
27.17 Rate limits
Section titled “27.17 Rate limits”Rack::Attack throttles apply per source IP for unauthenticated endpoints and per token for authenticated ones:
| Surface | Limit |
|---|---|
| Outlook pairing-code generation | 10 / minute / IP |
| Outlook pairing-code polling | 60 / minute / IP |
| Phishing report intake (add-in) | 30 / minute / IP |
| Spam whitelist download | 60 / minute / token |
Exceeding a bucket returns 429 Too Many Requests with a Retry-After header. The general authenticated API isn’t otherwise rate-limited beyond infrastructure-level abuse protection; keep usage under a few hundred requests/minute/token or contact us to raise it.
27.18 Cross-references
Section titled “27.18 Cross-references”- 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.