Zendesk Integration#
Connect Zendesk Support to PhishFort so that a ticket and an incident stay in step, in both directions:
- A support agent reports a threat from a ticket, and the PhishFort incident ID is written back to that ticket within seconds.
- Public replies on the ticket reach the PhishFort analyst working the incident, and attachments are added as evidence.
- PhishFort status changes, analyst messages, and action requests appear on the ticket as internal notes.
- Incidents PhishFort detects on its own open new tickets, so Zendesk stays your single queue.
The integration runs through a small connector that you build and host between the two APIs. PhishFort never calls Zendesk and never sees your Zendesk credentials; it stores the incident and returns its identifier, and your connector owns the ticket mapping and every Zendesk API call. Start with How it works for the correlation and timing rules, then follow Build the connector for the implementation contract.
Fastest path: let an AI coding agent build the connector
The connector is a well-defined, self-contained service, and coding agents (Claude Code, Cursor and other MCP-compatible tools) build it reliably when they can read this contract. Point your agent at the PhishFort Docs MCP server — a read-only, no-auth endpoint that serves this guide and every other page of the Client API documentation:
Then use the ready-made prompt in Building the connector with an AI agent; it names the pages to read and the facts to verify in the result. Setup instructions per tool are on the Docs MCP Server page. The Docs MCP server cannot read incidents or act on your account, so never give it your PhishFort API key or Zendesk credentials.
How it works#
| Purpose | Value |
|---|---|
| PhishFort Client API base URL | https://capi.phishfort.com/v1 |
| PhishFort authentication | x-api-key header |
| PhishFort incident identifier | The report response's id; treat it as an opaque string |
| Zendesk → PhishFort correlation | Store that id in a Zendesk ticket field |
| PhishFort → Zendesk correlation | Resolve webhook data.incidentId through your connector's durable link table |
| Recovery lookup | Search the Zendesk incident-ID field when the link table is unavailable |
The connector owns the relationship between the two systems. After a successful report, persist a link containing the PhishFort incident ID and Zendesk ticket ID before marking the ticket as linked. Do not derive either ID from a URL, prefix, threat value, or response text.
sequenceDiagram
autonumber
participant Z as Zendesk
participant C as Your connector
participant P as PhishFort Client API
Z->>C: Trigger with ticket ID and threat
C->>P: POST /v1/incident/tkd
P-->>C: 200 { id }
C->>C: Persist incident ID ↔ ticket ID
C->>Z: Save incident ID and add internal note
Note over P: Status or history update
P->>C: Signed webhook with data.incidentId
C-->>P: 2xx after durable enqueue
C->>C: Resolve ticket from link table
C->>Z: PUT /api/v2/tickets/{ticketId}.json
The connector exposes two HTTPS routes:
| Route | Caller | Responsibility |
|---|---|---|
/zendesk/webhook |
Zendesk | Verify Zendesk's signature, deduplicate the trigger, and enqueue the report / comment / action |
/webhooks/phishfort |
PhishFort | Verify PhishFort's signature, durably enqueue the event, and return 2xx within 5 seconds |
Behind them it needs two durable stores: a link table keyed by PhishFort incident ID with the Zendesk ticket ID as its value, and idempotency storage or a queue, because both Zendesk and PhishFort can retry deliveries. Keep neither only in process memory.
What syncs, and how#
The rules in this section apply to any connector, whatever language or hosting you choose.
Event mapping#
| PhishFort event or state | Zendesk action |
|---|---|
webhook.test |
Record connector health and return 2xx; do not expect an incident ID or update a ticket |
incident.status_changed |
Add an internal note and update the PhishFort Status field from data.status |
incident.status_changed with data.status = takedown_success |
Optionally solve the ticket after applying your own workflow checks |
incident.history_created |
Add data.historyEntry.message when present; otherwise add a generic update note |
incident.takedown_updated |
Add an internal note that takedown processing changed |
incident.action_required |
Reopen or assign the ticket; use data.waitForClient as the reason when it is a string, otherwise add a generic action-required note |
incident.created (ticket already linked) |
Optionally add a short "linked" note; otherwise no-op |
| Any event with no linked ticket | Open a ticket, or ignore if the connector should only track Zendesk-originated reports |
Three rules apply to every row:
- Fields can be absent. A webhook field is omitted when it does not apply to the incident or has not been populated yet. Always use fallbacks; never reject a valid event because an optional display field is missing.
- Threat values are attacker-controlled. Write
url,domain, andsubjectinto Zendesk defanged (hxxps[:]//…,example[.]com) so nobody clicks them from the ticket. - Ordering is unspecified. Keep the last event timestamp you applied per ticket, and do not move the status field or the ticket status backwards when an older event arrives late.
Recognise your own notes#
Comments the connector writes to Zendesk fire the Forward comment trigger, and comments it posts to PhishFort come back as incident.history_created. Both must be recognised and dropped, or the two systems will bounce the same text back and forth.
Zendesk → PhishFort. Do not forward a Zendesk comment the connector itself wrote. Filtering on the comment's author_id alone is not enough when the connector's OAuth user is also a human agent (common in small teams — every comment that person writes then looks like the connector's). Prefix connector notes with a fixed marker (for example PhishFort ·) and, in addition, keep the normalised text of each note you wrote per ticket for a day or two and skip Zendesk comments that match.
PhishFort → Zendesk. Keep the normalised text of each comment you posted per incident, and skip an incident.history_created whose data.historyEntry.message equals it. If you allow "contains" matching for messages PhishFort may wrap, apply it only to texts long enough to be distinctive (roughly 24+ characters) — a two-word comment such as "test" is contained in almost anything.
In both directions. Store the Zendesk comment id of everything you have forwarded. Forward comments verbatim with author set to the agent's email; a [Zendesk #123] prefix in the message is unnecessary because attribution is carried by author. Zendesk plain_body can contain ; normalise it before sending or comparing.
Build the connector#
Everything below is the contract for a connector. Use it to implement one in any language, or to review one written by an AI agent (see Building the connector with an AI agent). The steps run in the order you will need them: authorize with Zendesk, configure Zendesk, report, subscribe to PhishFort events, receive them, and send follow-up actions.
Prerequisites#
Collect these values before setup. Treat the API key, signing secrets, and OAuth client secret as secrets; the remaining values are ordinary connector configuration:
| Variable | Description |
|---|---|
PHISHFORT_API_KEY |
Client API key issued by PhishFort |
PHISHFORT_WEBHOOK_SECRET |
Secret returned when the PhishFort webhook is created (step 4) |
ZENDESK_SUBDOMAIN |
The first part of <subdomain>.zendesk.com |
ZENDESK_OAUTH_CLIENT_ID |
Unique identifier of the Zendesk OAuth client |
ZENDESK_OAUTH_CLIENT_SECRET |
Secret for a confidential, server-side OAuth client |
ZENDESK_OAUTH_REDIRECT_URI |
HTTPS connector callback registered for the authorization code flow (not needed for client credentials) |
ZENDESK_WEBHOOK_SECRET |
Signing secret of the Zendesk webhook (step 2) |
ZENDESK_INCIDENT_FIELD_ID |
Numeric ID of the PhishFort Incident ID ticket field |
ZENDESK_THREAT_FIELD_ID |
Numeric ID of the Threat URL ticket field |
ZENDESK_STATUS_FIELD_ID |
Numeric ID of the optional PhishFort Status ticket field |
1. Authorize with Zendesk#
Choose the OAuth client type that matches how the connector will be used:
| Connector | OAuth client | Token flow |
|---|---|---|
| One Zendesk account, operated internally | Local confidential client created in that account | Authorization code, or client credentials if access should not be tied to an interactive authorization |
| Multiple customer Zendesk accounts | Global OAuth client | Authorization code for each customer account |
A connector installed by multiple Zendesk customers must use a global OAuth client. Register and manage it through Zendesk's Marketplace developer portal; do not ask customers to provide API tokens. See Zendesk authentication and global OAuth client management.
For the workflow in this guide, request these resource-specific scopes:
tickets:write allows the connector to add notes, tags, and custom-field values. tickets:read supports recovery searches and follow-up workflows that read ticket data or attachments. users:read is needed to resolve the requester's email for reportedBy, to tell agents from end users when forwarding comments, and to call /api/v2/users/me — a token with only tickets:* scopes receives 403 You are missing the following required scopes: users:read. If the connector creates Zendesk ticket fields, webhooks, or triggers through the API instead of Admin Center, run that one-off setup with read write (or as an administrator) and switch back to the narrow scopes afterwards. Do not request broad read write access for steady-state operation. See Zendesk OAuth scopes.
Do not start a new integration with a Zendesk API token
Zendesk documents API-token authentication as deprecated and has announced that active API tokens stop working on April 30, 2027; newer accounts no longer offer an Add API token button at all. New connectors should use OAuth so credentials have limited scopes, expiration, and revocation. Keep the OAuth client secret and all tokens in the connector; never send them in Zendesk trigger payloads or browser code.
Register a local OAuth client#
For a connector used with one Zendesk account:
- In Zendesk Admin Center, open Apps and integrations → APIs → OAuth clients (direct URL:
https://<subdomain>.zendesk.com/admin/apps-integrations/apis/zendesk-api/settings/oauth-clients) and click Add OAuth client. Do not use the similarly named APIs → External OAuth clients or Connections → OAuth Clients pages. - Create a confidential OAuth client. Fill in a name and a Unique identifier (this is the value the token request sends as
client_id, for examplephishfort_connector). For the authorization code flow, enter the connector's exact HTTPS callback URL; the client credentials flow does not require one — leave Redirect URLs empty. Leave Scopes empty, or include every scope the connector will request; that field restricts which scopes tokens for this client may ask for. - Copy the client's unique identifier and secret immediately after saving. Zendesk displays the complete secret only once (it can be regenerated on the client's edit page). Save them as
ZENDESK_OAUTH_CLIENT_IDandZENDESK_OAUTH_CLIENT_SECRET. - For the authorization code flow, save the same callback URL as
ZENDESK_OAUTH_REDIRECT_URI. The value sent during authorization must match the registered URL.
Use a dedicated Zendesk integration user (for example an agent named "PhishFort Connector") with only the ticket permissions the connector needs, and create the OAuth client while signed in as that user: with the client credentials grant, tokens act as the user who created the client, and every note the connector writes is attributed to them. An administrator is still required to configure ticket fields, triggers, and webhooks. If the connector ends up sharing a Zendesk user with a human agent, do not rely on the comment author to tell the connector's notes from the human's comments — see Recognise your own notes.
Get a token#
For an internal confidential connector, the simplest option is the client_credentials grant:
curl -X POST 'https://YOUR_SUBDOMAIN.zendesk.com/oauth/tokens' \
-H 'Content-Type: application/json' \
-d '{
"grant_type": "client_credentials",
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"scope": "tickets:read tickets:write users:read",
"expires_in": 86400
}'
The resulting token inherits the permissions of the user associated with the OAuth client and has no refresh token. Request and atomically store another access token under a per-account lock before the current token expires. Do not use client credentials as a substitute for the per-customer authorization code flow in a distributed integration. See Zendesk grant types.
Authorization code flow (multi-tenant and marketplace connectors)
A connector installed by several Zendesk customers uses a global OAuth client and the authorization code flow for each account. The flow also works with a local client; the client credentials grant above is simply less work for a connector run inside one Zendesk account.
- Generate a cryptographically random, single-use
statevalue and store it temporarily in the connector. -
Send a Zendesk administrator to this URL, with every value URL-encoded:
-
At the callback, reject errors and any response whose
statedoes not exactly match the stored value. -
Exchange the returned authorization code within 120 seconds:
curl -X POST 'https://YOUR_SUBDOMAIN.zendesk.com/oauth/tokens' \ -H 'Content-Type: application/json' \ -d '{ "grant_type": "authorization_code", "code": "AUTHORIZATION_CODE", "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET", "redirect_uri": "https://connector.example.com/zendesk/oauth/callback", "scope": "tickets:read tickets:write users:read", "expires_in": 86400, "refresh_token_expires_in": 7776000 }' -
Store
access_token,refresh_token, their calculated expiry timestamps, the granted scope, and the Zendesk subdomain together in an encrypted durable store.
Access tokens can last from 5 minutes to 48 hours, and refresh tokens from 7 to 90 days. OAuth clients created on or after April 30, 2026 receive expiring access tokens by default. For an older OAuth client, include expires_in during the initial authorization so Zendesk returns a refresh token. See Zendesk OAuth token lifetimes.
Do not keep issued tokens only in environment variables: Zendesk can return a replacement refresh token, and the connector must persist it without a redeployment. In a multi-tenant connector, key each token record by the immutable Zendesk account ID when available, with the subdomain as connection metadata.
Refresh access tokens
Refresh an access token shortly before it expires. Also force a refresh once after an unexpected 401 Unauthorized, then retry the Zendesk request once. Use a per-account lock so concurrent workers cannot exchange the same refresh token at the same time. The helper below shows a single-tenant token store; for a multi-tenant connector, accept the Zendesk account ID and use it for every get, withLock, and put operation.
const TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1000;
async function getZendeskAccessToken({ forceRefresh = false } = {}) {
const current = await zendeskOAuthTokens.get();
if (
!forceRefresh &&
current.expiresAt > Date.now() + TOKEN_REFRESH_SKEW_MS
) {
return current.accessToken;
}
return zendeskOAuthTokens.withLock(async () => {
// Another worker may have refreshed the token while this worker waited.
const latest = await zendeskOAuthTokens.get();
if (
!forceRefresh &&
latest.expiresAt > Date.now() + TOKEN_REFRESH_SKEW_MS
) {
return latest.accessToken;
}
const response = await fetch(
`https://${process.env.ZENDESK_SUBDOMAIN}.zendesk.com/oauth/tokens`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: latest.refreshToken,
client_id: process.env.ZENDESK_OAUTH_CLIENT_ID,
client_secret: process.env.ZENDESK_OAUTH_CLIENT_SECRET,
expires_in: 86400,
refresh_token_expires_in: 7776000,
}),
},
);
const body = await response.json();
if (!response.ok) {
throw new Error(body.error_description ?? "Zendesk OAuth refresh failed");
}
const refreshedAt = Date.now();
const next = {
...latest,
accessToken: body.access_token,
refreshToken: body.refresh_token ?? latest.refreshToken,
expiresAt: refreshedAt + body.expires_in * 1000,
refreshExpiresAt:
body.refresh_token_expires_in === undefined
? latest.refreshExpiresAt
: refreshedAt + body.refresh_token_expires_in * 1000,
};
// Persist the access token and replacement refresh token atomically.
await zendeskOAuthTokens.put(next);
return next.accessToken;
});
}
zendeskOAuthTokens represents your encrypted durable token store and distributed-lock adapter. If Zendesk returns a replacement refresh token, it invalidates the old one; persist the new token atomically before releasing the lock. If refreshing fails because the refresh token is expired, revoked, or invalid, stop retrying and send an administrator through the authorization flow again.
2. Configure Zendesk#
Create ticket fields#
In Admin Center → Objects and rules → Tickets → Fields, create:
- Threat URL — the URL or domain to report to PhishFort. The same field can carry an email address, an E.164 phone number, or an IPv4 address; the connector then sends
incidentType+subjectinstead ofurl(see Report Incident). - PhishFort Incident ID — a text field for the report response's
id. It provides recovery visibility and is used for later comments, evidence uploads, and action requests. - PhishFort Status — an optional text or drop-down field for
data.status(ZENDESK_STATUS_FIELD_ID).
Fields can also be created through the API (POST /api/v2/ticket_fields.json with { "ticket_field": { "type": "text", "title": "PhishFort Status" } }); look existing fields up first with GET /api/v2/ticket_fields.json so re-running setup does not create duplicates.
Keep the numeric IDs (ZENDESK_INCIDENT_FIELD_ID, ZENDESK_THREAT_FIELD_ID, ZENDESK_STATUS_FIELD_ID). Zendesk represents a custom-field placeholder as {{ticket.ticket_field_<FIELD_ID>}} and updates a field through the Tickets API with { "id": FIELD_ID, "value": VALUE }. See Zendesk ticket fields.
Create the Zendesk webhook and triggers#
Create one Zendesk webhook that sends POST requests to your connector (for example /zendesk/webhook), then connect it to one trigger per workflow action. Each Zendesk webhook has its own signing secret (GET /api/v2/webhooks/{id}/signing_secret), so a single webhook shared by all triggers keeps verification simple; put the action in the body instead:
{ "kind": "report", "ticketId": "{{ticket.id}}", "mode": "takedown", "requesterEmail": "{{ticket.requester.email}}" }
Keep trigger payloads to identifiers. Placeholders such as {{ticket.latest_comment}} or a free-text threat field can contain quotes and line breaks that break the JSON body; have the connector read the ticket (GET /api/v2/tickets/{id}.json) and its comments (GET /api/v2/tickets/{id}/comments.json?sort_order=desc&include=users) with tickets:read instead. Reading comments through the API also gives you attachment URLs and the author's role.
Suggested triggers (create them in Admin Center → Objects and rules → Business rules → Triggers, or with POST /api/v2/triggers.json; the webhook action is { "field": "notification_webhook", "value": ["<WEBHOOK_ID>", "<json body>"] }):
| Trigger | Conditions (all) | Body |
|---|---|---|
| Report threat | tag phishfort_submit present, tag phishfort_linked absent, tag phishfort_unlinked absent |
kind: report |
| Forward comment | ticket updated, comment present — comment_is_public is true for public replies only, or is not_relevant for public and internal — tag phishfort_linked present |
kind: comment |
| Request takedown / move to monitoring / mark safe | tag phishfort_request_takedown (or phishfort_request_monitor, phishfort_mark_safe) present, tag phishfort_linked present |
kind: action |
In the API, tag conditions are { "field": "current_tags", "operator": "includes" | "not_includes", "value": "<tag>" }. Have the connector remove the request tag once it has acted, so re-adding the tag later is a new request rather than a retry.
Zendesk queues webhook jobs independently, may deliver them out of order, and retries some failures, so the connector must still deduplicate on a durable key such as zendesk-report:<ticketId>:<mode> for reports and the Zendesk comment id for comments. Because the trigger fires for the connector's own updates too, the connector must recognise its own notes and not forward them (see Recognise your own notes).
Verify X-Zendesk-Webhook-Signature against the raw body before trusting the request: the signature is base64(HMAC-SHA256(secret, timestamp + rawBody)) where timestamp is the X-Zendesk-Webhook-Signature-Timestamp header. Zendesk documents the exact algorithm in Verifying webhook authenticity.
3. Report the incident#
Call the takedown or monitoring endpoint from your connector. Never expose the PhishFort API key in a Zendesk browser app or trigger payload.
async function reportToPhishFort({ ticketId, requesterEmail, threatUrl, mode }) {
const action = mode === "monitor" ? "monitor" : "tkd";
const response = await fetch(
`https://capi.phishfort.com/v1/incident/${action}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": process.env.PHISHFORT_API_KEY,
},
body: JSON.stringify({
url: threatUrl,
reportedBy: requesterEmail,
comment: `Reported from Zendesk ticket #${ticketId}`,
}),
},
);
const body = await response.json();
if (!response.ok) {
throw Object.assign(new Error(body.message ?? "PhishFort report failed"), {
status: response.status,
});
}
return body;
}
This example reports URLs and domains. To report an email address, phone number, or IPv4 address, send incidentType and subject instead of url; see Report Incident.
Save the returned ID#
A successful, newly created report normally contains an id. Treat it as opaque — do not infer its storage system, format, or age from its prefix. Persist the connector link first, then write the ID to the PhishFort Incident ID field and add an internal note:
async function saveIncidentLink({ incidentId, ticketId }) {
// Replace incidentLinks with your durable database adapter. This write must
// be idempotent and must enforce one Zendesk ticket per PhishFort incident.
await incidentLinks.put({ incidentId, ticketId: String(ticketId) });
}
const report = await reportToPhishFort(reportRequest);
if (!report.id) {
throw new Error("PhishFort did not return an incident ID");
}
await saveIncidentLink({ incidentId: report.id, ticketId: reportRequest.ticketId });
await linkZendeskTicket(reportRequest.ticketId, report.id);
If the Zendesk update fails after the durable link is saved, retry only the Zendesk update. Do not submit the threat to PhishFort again.
async function linkZendeskTicket(ticketId, incidentId) {
const response = await fetch(
`https://${process.env.ZENDESK_SUBDOMAIN}.zendesk.com/api/v2/tickets/${ticketId}.json`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${await getZendeskAccessToken()}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
ticket: {
comment: {
body: `Linked to PhishFort incident ${incidentId}.`,
public: false,
},
custom_fields: [
{
id: Number(process.env.ZENDESK_INCIDENT_FIELD_ID),
value: incidentId,
},
],
additional_tags: ["phishfort_linked"],
},
}),
},
);
if (!response.ok) throw new Error(`Zendesk update failed: ${response.status}`);
}
Handle duplicate reports#
The report endpoints do not accept an idempotency key. Prevent duplicate submissions in your connector (deduplicate on zendesk-report:<ticketId>:<mode>), and handle both duplicate response forms:
409 Conflictwith a message that the incident already exists.200 OKwith a duplicate message but noid(possible for reports handled by an older processing path).
In either case, do not retry and do not parse the response message as a stable identifier. Add an internal note, tag the ticket phishfort_unlinked, and place it in a reconciliation queue. For any successful response, check body.id before writing the incident field. See Duplicate Reports.
4. Register the PhishFort webhook#
Register a webhook for the events your connector handles:
curl -X POST 'https://capi.phishfort.com/v1/webhooks' \
-H 'x-api-key: YOUR_API_KEY' \
-H 'Content-Type: application/json' \
-d '{
"url": "https://connector.example.com/webhooks/phishfort",
"events": [
"incident.created",
"incident.status_changed",
"incident.history_created",
"incident.takedown_updated",
"incident.action_required"
],
"description": "Zendesk production connector"
}'
Subscribe to incident.created as well if tickets should also be opened for incidents that did not start in Zendesk (see Open tickets for incidents PhishFort finds). The subscription can equally be created on the Webhooks page of the PhishFort dashboard, which also offers Test and Rotate Secret buttons.
Save the returned secret immediately. It is shown only on creation and rotation; if it is lost, rotate it rather than creating a second subscription. A subscription delivers events for incidents belonging to the client it was created under, so if you manage several clients or sub-clients you may end up with several subscriptions pointing at the same connector — accept every configured secret when verifying, and confirm delivery with a real incident on each client, not only with the test event. See Webhooks for payloads, verification code, retries, testing, and rotation.
Events follow processing, not the API response
PhishFort emits events when the incident change has been processed, typically within a couple of minutes of the report, status change, or comment — not synchronously with your API call. Two consequences for the connector: it will receive incident.created (and possibly incident.history_created for the report comment) for the incident it just reported itself, which must be a harmless no-op once the link exists; and a check for "no linked ticket" that runs the instant an event arrives can race a Zendesk-originated report that is still saving its link — wait briefly and re-check before opening a new ticket.
5. Receive PhishFort events#
The safe receive path is:
- Read the raw request bytes.
- Validate the timestamp and
X-PhishFort-Signature. - Parse the JSON only after signature verification.
- Persist the event or enqueue it durably.
- Return a
2xxresponse within 5 seconds. - Update Zendesk from a worker.
Do not start untracked background work after returning a response; serverless runtimes may stop it. Acknowledge only after the queue or durable record succeeds.
Resolve the ticket from data.incidentId through the durable link table:
async function zendeskTicketId(data) {
if (typeof data.incidentId !== "string" || data.incidentId.length === 0) {
return undefined;
}
const link = await incidentLinks.get(data.incidentId);
return link?.ticketId;
}
If the link table is temporarily unavailable or must be rebuilt, search Zendesk for the exact PhishFort incident ID stored in the custom field. Zendesk search indexing can lag by several minutes, so this is a recovery path rather than the normal receive path. Do not correlate on URL, domain, requester email, or timestamps; those values are not unique.
Add an internal note#
Update the ticket directly by ID:
async function addZendeskNote(ticketId, text, extraTicketFields = {}) {
const response = await fetch(
`https://${process.env.ZENDESK_SUBDOMAIN}.zendesk.com/api/v2/tickets/${ticketId}.json`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${await getZendeskAccessToken()}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
ticket: {
comment: { body: text, public: false },
...extraTicketFields,
},
}),
},
);
if (!response.ok) throw new Error(`Zendesk update failed: ${response.status}`);
}
Zendesk creates ticket comments through the Tickets API's update operation. See Ticket comments.
Open tickets for incidents PhishFort finds#
Most incidents on a brand-protection account are detected by PhishFort or reported through other channels, so they have no Zendesk ticket. To keep Zendesk the single queue, handle any event whose data.incidentId has no link (after the recovery search and the short grace period described above) by creating a ticket:
async function openTicketForIncident(event) {
const { data } = event;
const detail = await getIncident(data.incidentId).catch(() => undefined); // GET /v1/incident/{id}
const inc = detail?.data ?? data;
const threat = inc.url ?? inc.subject ?? inc.domain ?? "unknown threat";
const { ticket } = await zendeskCreateTicket({
subject: `[PhishFort] ${inc.status ?? "new"} — ${defang(threat)}`,
comment: { body: describeIncident(inc), public: false },
type: "incident",
tags: ["phishfort_linked", "phishfort_detected"],
custom_fields: [
{ id: Number(process.env.ZENDESK_INCIDENT_FIELD_ID), value: data.incidentId },
{ id: Number(process.env.ZENDESK_THREAT_FIELD_ID), value: threat },
],
});
await saveIncidentLink({ incidentId: data.incidentId, ticketId: ticket.id });
}
GET /v1/incident/{id} returns everything worth surfacing in the first note — status, threat taxonomy, registrar and hosting provider, partner blocklist listings, and the insights block (domain age, certificate, hosting) — so agents get the full picture without leaving Zendesk. Give the ticket a requester or group that fits your queue (requester, group_id); tickets created through the API otherwise default to the connector's user.
6. Send follow-up actions to PhishFort#
Read the PhishFort incident ID from the Zendesk custom field, then map explicit Zendesk workflow actions to these endpoints:
| Zendesk workflow action | Client API endpoint |
|---|---|
| Add a comment | POST /v1/incident/{id}/comment |
| Add evidence | POST /v1/incident/{id}/attach |
| Request takedown | POST /v1/incident/{id}/tkd |
| Move to monitoring | POST /v1/incident/{id}/monitor |
| Mark safe | POST /v1/incident/{id}/safe |
Use a dedicated tag, checkbox, trigger, or integration control for takedown, monitoring, and mark-safe requests. For comments, forward public agent replies by default and keep internal notes in Zendesk unless your team explicitly wants them shared with PhishFort analysts; never forward the connector's own notes (see Recognise your own notes). Post the comment text verbatim with author set to the agent's email address. Attachments on a forwarded comment can be downloaded from Zendesk (content_url) and uploaded with POST /v1/incident/{id}/attach, subject to the supported types and size limits; tell the agent in an internal note when a file was skipped.
Hardening#
Before enabling the integration on a production Zendesk account, check that the connector satisfies each of these:
- Keep PhishFort and Zendesk credentials only in the connector's secret store.
- Request only the Zendesk OAuth scopes the connector uses (
tickets:read tickets:write users:readfor steady state). - Encrypt OAuth tokens at rest and replace rotated refresh tokens atomically under a per-account lock.
- Reauthorize before the refresh token expires, or immediately after an unrecoverable refresh failure.
- Verify both Zendesk and PhishFort signatures against raw request bytes.
- Reject webhook timestamps outside a five-minute clock-skew window.
- Use HTTPS for both connector routes.
- Durably enqueue before acknowledging a PhishFort delivery.
- Make report submission idempotent per Zendesk ticket and action.
- Make Zendesk updates idempotent using an event fingerprint; a PhishFort delivery ID identifies an HTTP attempt, not the logical event.
- Recognise the connector's own notes and comments in both directions so nothing is echoed back.
- Give the connector a stable HTTPS hostname; both vendors store the webhook URL, so a temporary tunnel URL means re-registering both webhooks later.
- Treat webhook ordering as unspecified and compare timestamps or current state before regressing a ticket.
- Return
2xxto duplicate events after confirming the earlier event was durably stored. - Log ticket ID, incident ID, event type, and delivery ID, but never log secrets or full sensitive payloads.
- Test in a Zendesk sandbox and with the PhishFort
/webhooks/{id}/testendpoint before enabling the production trigger.
Reference#
Troubleshooting#
| Symptom | Check |
|---|---|
| The report trigger fires repeatedly | Add a trigger completion tag and a durable connector idempotency key |
| Report succeeded but the ticket has no incident ID | Handle 200 responses without id, and verify the Zendesk field update succeeded |
| Webhooks update the wrong ticket | Key the durable link table by the exact data.incidentId; never correlate on a threat value or timestamp |
| The incident link is missing | Search the exact incident-ID custom field, repair the durable link, and then process the event |
| PhishFort signature verification fails | Verify the unmodified raw body, the timestamp header, and the correct subscription secret |
| Zendesk signature verification fails | Use Zendesk's timestamp-plus-raw-body algorithm, which differs from PhishFort's algorithm |
| Duplicate internal notes appear | Deduplicate by a durable event fingerprint, not only by the delivery ID |
| A valid event is retried | Confirm the connector durably queued it and returned 2xx within 5 seconds |
| Test delivery works but ticket updates fail | Check Zendesk OAuth scopes, token ownership, field IDs, and API rate-limit responses |
| Test delivery works but no incident events arrive | Confirm the subscription belongs to the client (or sub-client) that owns the incident, and allow a couple of minutes for processing; check lastDeliveryAt with GET /v1/webhooks |
Zendesk returns 403 … missing the following required scopes: users:read |
Add users:read to the requested scopes (needed for /users/me and requester lookups); check the OAuth client's Scopes field is empty or includes it |
| Zendesk comments are never forwarded | The comment author is the connector's own user (shared account), the comment is an internal note while only public replies are forwarded, or the ticket lacks the phishfort_linked tag |
| A short Zendesk comment is dropped as an echo | Echo matching by "contains" is too loose for short texts; match exactly, or contain-match only for long texts |
| Notes in Zendesk are attributed to a human admin | The OAuth client was created by that user; recreate it while signed in as a dedicated integration agent |
Zendesk returns 401 Unauthorized |
Refresh once and retry once; if refresh fails, require administrator reauthorization instead of looping |
OAuth refresh intermittently returns invalid_grant |
Serialize refreshes per Zendesk account and atomically save any replacement refresh token |
A subscription shows lastDeliveryStatus: failed |
Fix the endpoint and send a test; the subscription remains active for future events |
Building the connector with an AI agent#
Coding agents build this integration reliably when they read the contract first. Give your assistant or development tool access to this documentation through the read-only Docs MCP server (Streamable HTTP, no authentication):
The Docs MCP server only serves documentation; it cannot read incidents or perform API actions, so never give it your PhishFort API key or Zendesk credentials. See Docs MCP Server for client-specific setup and the complete tool list. The connector itself uses ordinary REST requests and webhooks — MCP is not part of the integration at runtime.
A prompt that works — copy it into your agent as-is:
Using the PhishFort Docs MCP server, read the zendesk, webhooks, report-incident, add-comment,
add-attachments, request-incident-review and single-incident pages.
Build a small self-hosted connector (one HTTPS service with a durable store) that:
- reports a ticket to PhishFort when the tag phishfort_submit is added, stores the incident ID
in the ticket field and persists the link;
- forwards public agent comments and attachments to the incident;
- maps tags to takedown / monitor / safe actions;
- turns PhishFort webhook events into internal notes and status-field updates;
- opens a Zendesk ticket for incidents that have no linked ticket.
Verify both webhook signatures over the raw body, enqueue before acknowledging, deduplicate on
durable keys, and suppress echoes in both directions. Use Zendesk OAuth (client credentials)
with scopes "tickets:read tickets:write users:read". Include an idempotent setup script that
creates the Zendesk status field, webhook and triggers through the API and prints the
resulting IDs and signing secret.
Facts the agent must get right — worth checking in the result:
- Correlation is only ever the report response
id↔ ticket ID link;data.incidentIdin webhooks is that same identifier. - Events arrive minutes after the change, including for the connector's own report; a missing link right after an event is not proof the incident is unlinked.
- The
webhook.testpayload carries a syntheticincidentId; it must not create tickets. - Signatures: PhishFort
sha256=<hex>overtimestamp + "." + body(Unix seconds); Zendesk base64 overtimestamp + body(ISO-8601). Reject a five-minute skew on both. - Zendesk trigger payloads carry identifiers only; details are read back through the API.
- Comments are forwarded verbatim with
author; connector notes are recognised by content, not by author. - Duplicate reports can be
409or200without anid; neither is retried automatically.