Zendesk Integration
Use this guide to connect Zendesk Support to the PhishFort Client API with a conventional server-to-server integration. The result is a two-way workflow:
- A Zendesk trigger sends a ticket's threat details to your connector.
- The connector reports the threat to PhishFort and links the returned incident ID to the ticket.
- PhishFort status and history webhooks update the same Zendesk ticket.
- Follow-up comments, attachments, or action requests can be sent to the linked PhishFort incident.
This guide uses Zendesk triggers and webhooks, the PhishFort REST API, and a small connector service that you host. Keep both vendors' credentials in the connector.
Integration contract
| Purpose | Value to use |
|---|---|
| PhishFort 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-to-PhishFort correlation | Store that id in a Zendesk ticket field |
| PhishFort-to-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.
What PhishFort stores
PhishFort stores the incident and returns its identifier. It does not store your Zendesk credentials or Zendesk ticket mapping, and it does not call Zendesk directly. Your connector owns both the mapping and the Zendesk API calls.
Architecture
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/12345.json
The connector needs two HTTPS routes:
| Route | Caller | Responsibility |
|---|---|---|
/zendesk/report |
Zendesk | Verify Zendesk's signature, deduplicate the trigger, and report the incident |
/webhooks/phishfort |
PhishFort | Verify PhishFort's signature, durably enqueue the event, and return 2xx within 5 seconds |
The connector needs a durable link table keyed by PhishFort incident ID, with the Zendesk ticket ID as its value. It also needs durable idempotency storage or a queue because both Zendesk and PhishFort can retry deliveries. Do not keep either only in process memory.
Before you start
Collect these values before setup. Treat the API keys, signing secrets, and OAuth client secret as secrets; the remaining values can be normal connector configuration:
| Variable | Description |
|---|---|
PHISHFORT_API_KEY |
Client API key issued by PhishFort |
PHISHFORT_WEBHOOK_SECRET |
Secret returned when the PhishFort webhook is created |
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 |
ZENDESK_WEBHOOK_SECRET |
Secret used to verify Zendesk webhook requests |
ZENDESK_INCIDENT_FIELD_ID |
Numeric ID of the PhishFort Incident ID ticket field |
Configure Zendesk OAuth
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. If the connector creates Zendesk webhooks or triggers through the API instead of Admin Center, also request webhooks:write or triggers:write as applicable. Do not request broad read write access when resource-specific scopes are sufficient. See Zendesk OAuth scopes.
Do not start a new integration with a Zendesk API token
Zendesk documents API-token authentication as deprecated. 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.
- Create a confidential OAuth client. For the authorization code flow, enter the connector's exact HTTPS callback URL; the client credentials flow does not require one.
- Copy the client's unique identifier and secret immediately. Zendesk displays the complete secret only once. 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 with only the ticket permissions the connector needs. An administrator is still required to create the OAuth client and configure ticket fields, triggers, and webhooks.
The authorization code flow below works for local and global clients.
Authorize the connector
- 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", "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.
For an internal confidential connector, Zendesk also supports 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",
"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.
1. Configure Zendesk
Create ticket fields
In Admin Center → Objects and rules → Tickets → Fields, create:
- Threat URL — the URL or domain to report to PhishFort.
- 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.
Keep the numeric IDs. 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 report webhook and trigger
Create a Zendesk webhook that sends POST requests to your connector's /zendesk/report route. Connect it to a ticket trigger and send this JSON body, replacing <THREAT_FIELD_ID>:
{
"ticketId": "{{ticket.id}}",
"requesterEmail": "{{ticket.requester.email}}",
"threatUrl": "{{ticket.ticket_field_<THREAT_FIELD_ID>}}",
"mode": "takedown"
}
Use a dedicated trigger condition such as a tag (phishfort_submit) or checkbox. Add another condition that the ticket does not contain your completion tag (phishfort_linked). 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>.
Verify X-Zendesk-Webhook-Signature against the raw body before trusting the request. Zendesk documents the exact algorithm in Verifying webhook authenticity.
2. 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. Save it to the PhishFort Incident ID custom field and add an internal note:
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}`);
}
Treat the incident ID as opaque. Do not infer its storage system, format, or age from its prefix.
Persist the connector link before updating Zendesk:
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.
Handle duplicate reports
There is no idempotency header on the report endpoint. Prevent duplicate submissions in your connector, 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 loop 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.
3. 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.status_changed",
"incident.history_created",
"incident.takedown_updated",
"incident.action_required"
],
"description": "Zendesk production connector"
}'
Save the returned secret immediately. It is shown only on creation and rotation. See Webhooks for payloads, verification code, retries, testing, and rotation.
4. 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.
Suggested 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 |
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 |
Webhook fields can be absent when they do not apply to an incident or have not been populated yet. Always use fallbacks; do not reject a valid event because an optional display field is missing.
5. 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 each command. Do not forward every Zendesk comment automatically; that can expose internal notes or create a loop when the connector writes PhishFort updates back to the ticket.
Reliability and security checklist
- Keep PhishFort and Zendesk credentials only in the connector's secret store.
- Request only the Zendesk OAuth scopes the connector uses.
- 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.
- 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.
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 |
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 |
Optional: use the Docs MCP server
The integration above uses ordinary REST requests and webhooks; MCP is not required. To make this guide and the rest of the PhishFort Client API documentation available inside your preferred MCP-compatible AI assistant or development tool, connect this read-only Streamable HTTP endpoint:
The Docs MCP server requires no authentication and cannot access incidents or perform API actions. Never provide it with your PhishFort API key or Zendesk credentials.
Once connected, ask the tool to read the zendesk documentation page before generating or reviewing integration code. See Docs MCP Server for client-specific setup instructions and the complete tool list.