← Phish Desk / API
Tokens

Drive Phish Desk from your own code

The page reads a reported email’s full source in your browser for free: authentication results and DMARC alignment, the relay path, every link (redirect wrappers unwrapped), every attachment with its SHA-256, look-alike domains, content cues and a verdict floor. Its one paid step, the triage, is available over HTTP: you send one JSON object with task: "triage" and a facts string that carries that check, and the model replies with one JSON object: a verdict, a category, the evidence, what each person should do, the indicators to block and a reply to the person who reported it.

Base URL: https://api.skillsafe.ai/v1/app-api. Every call sends Content-Type: application/json and, once you have a token, Authorization: Bearer <token>. The paths below (/guest, /me, /estimate, /run, /jobs/{job_id}, /run-stream) are relative to it, exactly as sdk.js calls them.

The response envelope

Responses are JSON of the form {"ok": ..., "data": ..., "error": {...}}. The page’s SDK (sdk.js) decides success from the HTTP status, not from ok: on a 2xx it returns data; otherwise it raises an error whose message is error.message (or the HTTP status text when the body carries none), with status, code (error.code) and details (error.details) attached. A body that is not JSON is treated as {}.

{ "ok": true,  "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "details": { ... } } }

Error codes

Branch on the HTTP status and error.code, show error.message. The page handles 401 and 402 by name; the rest reach it as a generic failure.

HTTPerror.codeWhat it means / what to do
400VALIDATION_ERRORThe body is not a JSON object or a declared field has the wrong type. task and facts are both strings; sending facts as an object is the usual cause.
401UNAUTHORIZEDNo token, or one that expired or was revoked. The page forgets the signed-in user and says “Your session expired - sign in again.” Get a new token.
402INSUFFICIENT_CREDITSThe balance cannot cover the run. The page says “Not enough credits for this run.” with a top-up link, and tries never to submit into a 402: it disables the run button when the /me balance is below the estimate’s min_credits.
404NOT_FOUNDA wrong path, or a job id that does not belong to this token.
409CONFLICTAn Idempotency-Key replayed with a different body. Send the original body, or use a new key (the page bumps its :a<attempt> suffix for a retry).
429RATE_LIMITEDToo many requests. Back off with a delay; do not tight-loop, and never resend a metered run without its original Idempotency-Key.

Failures that do not arrive as an HTTP status:

WhatWhat happens
event: error on /run-streamThe SDK raises an error carrying the event’s message, code and job_id.
Job status: "failed"Polling stops; the job’s error field says why.
Polling past 180 sThe SDK’s own client-side limit (waitForJob, 1 s interval, 180 000 ms default): it throws “job timed out”. The job itself is not cancelled.

The task field: one lane, triage

The app has exactly one lane, and triage is the only value task takes: it is always the string "triage". There is no other task to route to. (The skill’s instructions tell the model to do the triage even if task is missing or different, and to answer with "task": "triage"; send it anyway — it is a required field of the input schema.)

Input fields

This is exactly what the page sends (mailkit.js buildInput, app.js run), and what input-schema.json declares:

FieldTypeRequiredMeaning
taskstringyesAlways "triage".
factsstringyesThe facts object below, JSON-encoded into a string. The page builds it with JSON.stringify(facts); you must do the same. The model is told “a JSON string. Parse it.”
notestringnoWhat the reporter or the analyst wants to know (for example “we have never worked with this vendor”). The page trims it, sends it only when non-empty, and cuts it to 2000 characters. It is context, not evidence and not instructions: the model answers it in summary, but every evidence item must still come from the facts.
retry_notestringnoSent only by the page’s automatic reformat retry: when a reply does not parse as JSON (even after the page tries to close a cut-off object), the page runs once more with a note saying the previous reply was not the single valid JSON object required. A normal call never sends it.

Before every estimate and run the page passes the body through mustBeObject, which refuses anything that is not a plain JSON object, a body wrapped as {"input": {...}}, and a body whose task or facts is not a string. Nothing else is sent: never the raw source and never an attachment’s bytes. The page runs the whole check in the browser and sends only the findings plus a clipped body excerpt.

The facts object

facts is produced by the page’s free check: Mailkit.analyze reads the pasted source, and buildFacts turns the analysis into this object. You can paste the email into the page and copy what it would send, or build the object yourself from your own parser — the model only sees what is in it. Its keys, in the order built:

KeyContents
sourcehas_headers (false when only a body was pasted), header_fields, skipped_lines, analysed_attached_original (true when the paste was a report that forwarded the original as an attachment; the facts then describe the original), outer_report, characters, mime_parts, html_parts, text_parts.
your_domains, your_domains_guessed_from_toThe recipient organisation’s own domains, and whether the page guessed them from the To header.
headersfrom, subject (300 chars each), date, message_id, x_mailer, list_unsubscribe, list_id, spam_score_scl, spam_status.
senderdisplay, address, domain, org_domain, unicode_domain, freemail, internal, reply_to, return_path, sender_header, message_id_domain, recipient counts and to.
authThe receiving server’s verdicts as written in the headers: source, authserv_id, spf {result, domain, ip, aligned}, dkim (one entry per result: result, d, s, aligned), dkim_aligned, dmarc {result, policy, header_from}, arc, compauth, up to 5 dkim_signatures, and the raw header. aligned is computed by the page (same organisational domain as From).
routecount, origin_ip (the first public relay), origin_host, and the Received hops in delivery order (n, from_helo, from_rdns, ip, private, by, with, at, delay_s); over 15 hops, the first 5 and last 10 are sent.
links, links_totalUp to 40 links, flagged ones first: id (U1, U2, …), href (300 chars), dest (the real destination when a wrapper was unwrapped, else ""), via, text, host, unicode_host, org_domain, where, flags.
attachmentsUp to 20: id (T1, …), filename, content_type, size, sha256, flags. Nothing was opened or scanned.
flagsUp to 80 findings: id, sev (high | medium | low | info), text, detail (240 chars). IDs by family: X source, A authentication, S sender, L links, T attachments, C content, R route (the full table is in the skill and on the app page).
verdict_floor, floor_reasonsThe least severe verdict these facts alone allow (benign | suspicious | malicious) and why. malicious for an executable or disguised attachment, an HTML attachment asking for a sign-in, a sender dressed up as one of your domains, your own domain failing authentication, a disguised link on a sign-in request, or a brand or look-alike sender that failed authentication; otherwise suspicious for any high flag or two or more medium flags.
body_excerptThe readable body text, up to 6000 characters; a longer body keeps its start and its end with a marker for the middle.
notesPlain sentences about what was cut before sending.

Worked example: an unfamiliar vendor invoice

A fully authenticated message from billing@corvid-logistics.example that asks for an overdue invoice to be paid by Friday and links to the invoice on WeTransfer. The check raises S11 (external sender, low), L10 (file-sharing link, medium) and C02 (payment pressure, medium); two medium flags put the floor at suspicious. This is the facts object buildFacts produces for it; save it as facts.json to run the samples below:

{
  "source": {
    "has_headers": true,
    "header_fields": 12,
    "skipped_lines": 0,
    "analysed_attached_original": false,
    "outer_report": null,
    "characters": 1306,
    "mime_parts": 1,
    "html_parts": 0,
    "text_parts": 1
  },
  "your_domains": [
    "pellridge-labs.example"
  ],
  "your_domains_guessed_from_to": false,
  "headers": {
    "from": "\"Accounts | Corvid Logistics\" <billing@corvid-logistics.example>",
    "subject": "Invoice CL-20931 - September freight",
    "date": "Mon, 28 Sep 2026 09:15:27 +0000",
    "message_id": "<a81c2e7f@corvid-logistics.example>",
    "x_mailer": "",
    "list_unsubscribe": false,
    "list_id": "",
    "spam_score_scl": "",
    "spam_status": ""
  },
  "sender": {
    "display": "Accounts | Corvid Logistics",
    "address": "billing@corvid-logistics.example",
    "domain": "corvid-logistics.example",
    "org_domain": "corvid-logistics.example",
    "unicode_domain": "corvid-logistics.example",
    "freemail": false,
    "internal": false,
    "reply_to": [],
    "return_path": "billing@corvid-logistics.example",
    "sender_header": "",
    "message_id_domain": "corvid-logistics.example",
    "to_count": 1,
    "cc_count": 0,
    "to": [
      "s.meyer@pellridge-labs.example"
    ]
  },
  "auth": {
    "source": "Authentication-Results",
    "authserv_id": "mx.pellridge-labs.example",
    "spf": {
      "result": "pass",
      "domain": "corvid-logistics.example",
      "ip": "192.0.2.61",
      "aligned": true
    },
    "dkim": [
      {
        "result": "pass",
        "d": "corvid-logistics.example",
        "s": "mail",
        "aligned": true
      }
    ],
    "dkim_aligned": true,
    "dmarc": {
      "result": "pass",
      "policy": "none",
      "header_from": "corvid-logistics.example"
    },
    "arc": "",
    "compauth": "",
    "dkim_signatures": [],
    "raw": "mx.pellridge-labs.example; spf=pass (sender IP is 192.0.2.61) smtp.mailfrom=corvid-logistics.example; dkim=pass header.d=corvid-logistics.example header.s=mail; dmarc=pass action=none header.from=corvid-logistics.example"
  },
  "route": {
    "count": 1,
    "origin_ip": "192.0.2.61",
    "origin_host": "smtp.corvid-logistics.example",
    "hops": [
      {
        "n": 1,
        "from_helo": "smtp.corvid-logistics.example",
        "from_rdns": "smtp.corvid-logistics.example",
        "ip": "192.0.2.61",
        "private": false,
        "by": "mx.pellridge-labs.example",
        "with": "ESMTPS",
        "at": "2026-09-28T09:15:31.000Z",
        "delay_s": null
      }
    ]
  },
  "links": [
    {
      "id": "U1",
      "href": "https://wetransfer.com/downloads/5c1f0e9a7b2d/corvid-cl-20931",
      "dest": "",
      "via": "",
      "text": "",
      "host": "wetransfer.com",
      "unicode_host": "",
      "org_domain": "wetransfer.com",
      "where": "plain text",
      "flags": [
        "L10"
      ]
    }
  ],
  "links_total": 1,
  "attachments": [],
  "flags": [
    {
      "id": "S11",
      "sev": "low",
      "text": "External sender: the From domain is not one of your domains",
      "detail": "corvid-logistics.example is not pellridge-labs.example"
    },
    {
      "id": "L10",
      "sev": "medium",
      "text": "Link to a free hosting, form or file-sharing platform where anyone can publish",
      "detail": "U1 wetransfer.com"
    },
    {
      "id": "C02",
      "sev": "medium",
      "text": "Payment or bank-detail change, wire transfer or invoice pressure",
      "detail": "...idation. It is now overdue on our side, so please process this payment by Friday. The invoice and delivery notes are on..."
    }
  ],
  "verdict_floor": "suspicious",
  "floor_reasons": [
    "medium-severity flags L10, C02"
  ],
  "body_excerpt": "Hello,\n\nPlease find our invoice CL-20931 for the September freight consolidation. It is now overdue on our side, so please process this payment by Friday.\n\nThe invoice and delivery notes are on WeTransfer:\nhttps://wetransfer.com/downloads/5c1f0e9a7b2d/corvid-cl-20931\n\nKind regards,\nPriya Raman\nAccounts, Corvid Logistics",
  "notes": []
}

The full request body, with facts stringified (this is what buildInput(facts, "We have never worked with Corvid Logistics as far as I know.") returns):

{"task":"triage","facts":"{\"source\":{\"has_headers\":true,\"header_fields\":12,\"skipped_lines\":0,\"analysed_attached_original\":false,\"outer_report\":null,\"characters\":1306,\"mime_parts\":1,\"html_parts\":0,\"text_parts\":1},\"your_domains\":[\"pellridge-labs.example\"],\"your_domains_guessed_from_to\":false,\"headers\":{\"from\":\"\\\"Accounts | Corvid Logistics\\\" <billing@corvid-logistics.example>\",\"subject\":\"Invoice CL-20931 - September freight\",\"date\":\"Mon, 28 Sep 2026 09:15:27 +0000\",\"message_id\":\"<a81c2e7f@corvid-logistics.example>\",\"x_mailer\":\"\",\"list_unsubscribe\":false,\"list_id\":\"\",\"spam_score_scl\":\"\",\"spam_status\":\"\"},\"sender\":{\"display\":\"Accounts | Corvid Logistics\",\"address\":\"billing@corvid-logistics.example\",\"domain\":\"corvid-logistics.example\",\"org_domain\":\"corvid-logistics.example\",\"unicode_domain\":\"corvid-logistics.example\",\"freemail\":false,\"internal\":false,\"reply_to\":[],\"return_path\":\"billing@corvid-logistics.example\",\"sender_header\":\"\",\"message_id_domain\":\"corvid-logistics.example\",\"to_count\":1,\"cc_count\":0,\"to\":[\"s.meyer@pellridge-labs.example\"]},\"auth\":{\"source\":\"Authentication-Results\",\"authserv_id\":\"mx.pellridge-labs.example\",\"spf\":{\"result\":\"pass\",\"domain\":\"corvid-logistics.example\",\"ip\":\"192.0.2.61\",\"aligned\":true},\"dkim\":[{\"result\":\"pass\",\"d\":\"corvid-logistics.example\",\"s\":\"mail\",\"aligned\":true}],\"dkim_aligned\":true,\"dmarc\":{\"result\":\"pass\",\"policy\":\"none\",\"header_from\":\"corvid-logistics.example\"},\"arc\":\"\",\"compauth\":\"\",\"dkim_signatures\":[],\"raw\":\"mx.pellridge-labs.example; spf=pass (sender IP is 192.0.2.61) smtp.mailfrom=corvid-logistics.example; dkim=pass header.d=corvid-logistics.example header.s=mail; dmarc=pass action=none header.from=corvid-logistics.example\"},\"route\":{\"count\":1,\"origin_ip\":\"192.0.2.61\",\"origin_host\":\"smtp.corvid-logistics.example\",\"hops\":[{\"n\":1,\"from_helo\":\"smtp.corvid-logistics.example\",\"from_rdns\":\"smtp.corvid-logistics.example\",\"ip\":\"192.0.2.61\",\"private\":false,\"by\":\"mx.pellridge-labs.example\",\"with\":\"ESMTPS\",\"at\":\"2026-09-28T09:15:31.000Z\",\"delay_s\":null}]},\"links\":[{\"id\":\"U1\",\"href\":\"https://wetransfer.com/downloads/5c1f0e9a7b2d/corvid-cl-20931\",\"dest\":\"\",\"via\":\"\",\"text\":\"\",\"host\":\"wetransfer.com\",\"unicode_host\":\"\",\"org_domain\":\"wetransfer.com\",\"where\":\"plain text\",\"flags\":[\"L10\"]}],\"links_total\":1,\"attachments\":[],\"flags\":[{\"id\":\"S11\",\"sev\":\"low\",\"text\":\"External sender: the From domain is not one of your domains\",\"detail\":\"corvid-logistics.example is not pellridge-labs.example\"},{\"id\":\"L10\",\"sev\":\"medium\",\"text\":\"Link to a free hosting, form or file-sharing platform where anyone can publish\",\"detail\":\"U1 wetransfer.com\"},{\"id\":\"C02\",\"sev\":\"medium\",\"text\":\"Payment or bank-detail change, wire transfer or invoice pressure\",\"detail\":\"...idation. It is now overdue on our side, so please process this payment by Friday. The invoice and delivery notes are on...\"}],\"verdict_floor\":\"suspicious\",\"floor_reasons\":[\"medium-severity flags L10, C02\"],\"body_excerpt\":\"Hello,\\n\\nPlease find our invoice CL-20931 for the September freight consolidation. It is now overdue on our side, so please process this payment by Friday.\\n\\nThe invoice and delivery notes are on WeTransfer:\\nhttps://wetransfer.com/downloads/5c1f0e9a7b2d/corvid-cl-20931\\n\\nKind regards,\\nPriya Raman\\nAccounts, Corvid Logistics\",\"notes\":[]}","note":"We have never worked with Corvid Logistics as far as I know."}

A reply for these facts that satisfies the contract below and passes the page’s own reconciliation with no disagreements:

{
  "task": "triage",
  "verdict": "suspicious",
  "category": "bec_payment_fraud",
  "confidence": "medium",
  "headline": "An invoice from corvid-logistics.example, a vendor you say you don't recognise, demands payment by Friday and points to a file-sharing link instead of an attached invoice.",
  "auth": {
    "spf": "pass",
    "dkim": "pass",
    "dmarc": "pass",
    "aligned": true,
    "reading": "SPF, DKIM and DMARC all pass and align to corvid-logistics.example, so the message really was sent by that domain's own mail systems and the From address was not spoofed. That only proves the sender controls corvid-logistics.example; it says nothing about whether Corvid Logistics is a vendor your organisation actually works with."
  },
  "evidence": [
    { "flag": "C02", "finding": "The body says the invoice is overdue on the sender's side and asks for payment to be processed by Friday, a payment-pressure and urgency pattern.", "weight": "moderate", "points_to": "malicious" },
    { "flag": "L10", "finding": "The invoice and delivery notes are not attached; instead the email links to a WeTransfer download page, a free file-sharing platform anyone can post to.", "weight": "moderate", "points_to": "malicious" },
    { "flag": "", "finding": "SPF, DKIM and DMARC all pass and are aligned to the From domain, so this is not a spoofed or look-alike sender address at the technical level.", "weight": "moderate", "points_to": "benign" },
    { "flag": "S11", "finding": "The sender's domain is external to your organisation, which is expected for any real outside vendor and is weak evidence on its own.", "weight": "weak", "points_to": "malicious" },
    { "flag": "", "finding": "The single relay hop originates from smtp.corvid-logistics.example at 192.0.2.61, consistent with mail sent directly from the claimed sender's own domain, with no backwards timestamps or other route anomalies.", "weight": "weak", "points_to": "benign" }
  ],
  "sender": "The message claims to be from the Accounts team at Corvid Logistics, billing@corvid-logistics.example. SPF, DKIM and DMARC all pass and align to that same domain, so it genuinely was sent by Corvid Logistics' own mail systems and the address was not spoofed or impersonated at the technical level. Whether Corvid Logistics is a company your organisation has actually done business with is a separate question the authentication cannot answer.",
  "links": [
    { "id": "U1", "risk": "medium", "assessment": "This is a link to a WeTransfer download page rather than the invoice being attached directly. WeTransfer is a free file-sharing service anyone can post to, so a link there does not by itself confirm who placed the file, and it is where the claimed invoice and delivery notes are hosted." }
  ],
  "attachments": [],
  "actions": [
    { "owner": "finance", "priority": "now", "step": "Hold payment on invoice CL-20931; do not release funds until Corvid Logistics is confirmed as a real, approved vendor." },
    { "owner": "finance", "priority": "now", "step": "Verify the invoice and the vendor by calling a phone number for Corvid Logistics from your own vendor records or a prior contract, never a number or address taken from this email." },
    { "owner": "recipient", "priority": "now", "step": "Do not open the download link or reply with any payment or account details until finance confirms the vendor." },
    { "owner": "soc", "priority": "soon", "step": "Check procurement and vendor records for any prior relationship with Corvid Logistics; if none exists, treat this as an unsolicited invoice pending verification." },
    { "owner": "mail_admin", "priority": "soon", "step": "Search mailboxes for other messages mentioning invoice CL-20931 or from corvid-logistics.example and hold them pending verification." },
    { "owner": "recipient", "priority": "later", "step": "If Corvid Logistics is confirmed as a genuine new vendor, record them in verified vendor contacts so a future invoice from them is not mistaken for a scam." }
  ],
  "iocs": [
    { "type": "email", "value": "billing@corvid-logistics.example", "note": "Sender address on this unverified invoice; search other mailboxes for messages from it while the vendor is confirmed." },
    { "type": "domain", "value": "corvid-logistics.example", "note": "Sending domain for this unverified vendor; watch for further messages from it pending verification." },
    { "type": "ip", "value": "192.0.2.61", "note": "Origin IP that sent this message; keep for reference in case the vendor turns out not to be genuine." },
    { "type": "url", "value": "https://wetransfer.com/downloads/5c1f0e9a7b2d/corvid-cl-20931", "note": "Where the claimed invoice and delivery notes are hosted; do not open until the vendor is verified." }
  ],
  "reporter_reply": "Thanks for flagging this - you were right to be cautious. This email is asking for payment on an invoice from a company we don't have a known relationship with, and it points to a download page instead of attaching the invoice directly, so please don't click that link or send any payment or details yet. We're confirming with the sender through a phone number or contact we already trust, not anything in this message, before anything is paid. We'll let you know once we've worked out whether this is a real vendor.",
  "summary": "This is an unsolicited-looking invoice (CL-20931) from billing@corvid-logistics.example asking for payment by Friday, with the invoice and delivery notes hosted on a WeTransfer link rather than attached to the email. SPF, DKIM and DMARC all pass and align, so the message genuinely came from the corvid-logistics.example domain and was not spoofed; authentication alone cannot confirm whether Corvid Logistics is a vendor your organisation actually works with. You noted you don't recognise this vendor, which, combined with the payment urgency and the file-sharing link standing in for the invoice, is consistent with either a legitimate but unfamiliar new-vendor invoice or a fraudulent one, and the technical facts alone do not settle which. Hold the payment and verify the vendor and the invoice through a phone number or contact already on file before paying or opening the link. No attachments were present, and no other authentication or route anomalies were found."
}

The output contract

The model is instructed (SKILL.md) to reply with one JSON object and nothing else — no prose, no code fences:

Every text field is plain text (no Markdown, no HTML). The model never claims a link was visited, a domain looked up or an attachment opened, and never names a threat actor.

The page reconciles the reply; the API does not

What comes back from the API is the model’s raw text in output.output. The page does more with it before showing anything (recon.js), and a direct API caller gets none of this:

If you call the API directly, run your own checks like these before any indicator reaches a blocklist.

Step 1 — a tiny client helper and a token

One function per language that does what sdk.js does for every call: JSON in, bearer token if you have one, data out on a 2xx, an error carrying status, error.code and error.message otherwise. The later steps assume this helper is in the same file (for Go, Java and C#, the later snippets go inside main / the top-level statements).

Then a token. Two kinds work:

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="${SKILLSAFE_TOKEN:-YOUR_TOKEN}"   # see below

# api METHOD PATH [extra curl args...] -- prints the whole envelope; pipe to jq
api() {
  method="$1"; path="$2"; shift 2
  curl -sS -X "$method" "$BASE$path" \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer $TOKEN" "$@"
}
# success: jq '.data'      failure: jq '.error | {code, message}'

# ---- get a token
# personal token: export SKILLSAFE_TOKEN=... (from /tokens.html) and skip this
# guest token: no Authorization header on this one call
TOKEN=$(curl -sS -X POST "$BASE/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"phish-desk"}' | jq -r '.data.token')
echo "guest token: ${TOKEN:0:12}..."

Step 2 — GET /me

Who the token belongs to and what it can spend. sdk.js documents the shape as {subject_type, subject_id, credits, profile?}. The page treats the caller as signed in only when subject_type is "user", shows credits as the balance (1 credit = $0.0001, so it divides by 10 000 for the dollar figure), and compares it with the estimate in step 3 before it lets you run.

api GET /me | jq '.data // .error'

Step 3 — POST /estimate

Price the triage before you run it. The body is the same input object you will send to /run (task, facts as a string, optional note); no job is created and nothing is charged. From the response the page reads:

FieldMeaning
hold_creditsA reservation, not a price: the worst case the platform holds while the run is in flight. The page shows it as “reserves N credits”. What you actually pay is the job’s charged_credits (step 4), normally less; the rest of the hold is released. A balance below the hold is allowed but the reply may be cut short (truncated).
min_creditsThe smallest balance that can start a run. Below it the page disables the run button, because the call would be a 402.
sponsor_enabledWhen true, a caller who is not signed in may run the triage; otherwise the page asks them to sign in.
warningsInput warnings (for example a missing required field). They do not refuse the call, so check them before you run.

The page re-prices whenever the paste or the note changes. The amounts move with the model’s rates and grow with the size of facts, so read them from the call rather than hard-coding them.

# facts.json = the facts object from the worked example; jq -c turns it into ONE string
BODY=$(jq -n --arg facts "$(jq -c . facts.json)" --arg q "We have never worked with Corvid Logistics as far as I know." \
  '{task: "triage", facts: $facts, note: $q}')

api POST /estimate -d "$BODY" \
  | jq '.error // (.data | {hold_credits, min_credits, sponsor_enabled, warnings, model, model_alias, markup_bps})'

Step 4 — POST /run, then poll the job

Send the same input to /run with an Idempotency-Key header. Its data carries a job_id. Poll GET /jobs/{job_id} the way sdk.js waitForJob does: every second until status is succeeded or failed, giving up on the client side after 180 seconds. On the finished job, read:

Idempotency key. Resending a request with the same key must not start a second paid run, so derive the key from the input, not from the clock. The page uses phish-desk:triage:<hash>:a<attempt>, where <hash> is a 32-bit djb2 hash of JSON.stringify(input) in hex, a dash, and the string’s length in hex, and <attempt> is 1 for a normal run and 2 for the automatic reformat retry (which also adds retry_note). For the worked example that is phish-desk:triage:3d368f81-e64:a1. The format is the page’s convention, not a requirement: any stable, unique string does. The samples below use a SHA-256 prefix.

The page itself uses /run-stream (step 5), not /run; both take the same body and header.

KEY="phish-desk:triage:$(printf '%s' "$BODY" | shasum -a 256 | cut -c1-16):a1"

JOB=$(api POST /run -H "Idempotency-Key: $KEY" -d "$BODY" | jq -r '.data.job_id')

for i in $(seq 180); do                       # 1 s interval, 180 s client-side limit
  J=$(api GET "/jobs/$JOB")
  S=$(printf '%s' "$J" | jq -r '.data.status')
  if [ "$S" = "succeeded" ] || [ "$S" = "failed" ]; then break; fi
  sleep 1
done

printf '%s' "$J" | jq '.data | {status, truncated, charged_credits, error}'
printf '%s' "$J" | jq -r '.data.output.output' | jq '{verdict, category, confidence, headline, iocs}'

Step 5 — POST /run-stream (Server-Sent Events)

The alternative to step 4, and what the page uses: same body, same Idempotency-Key header, one request. Run one or the other for a given input, not both. The response is text/event-stream. sdk.js splits it into frames on a blank line, takes the event name from an event: line (default message) and concatenates the data: lines, skips frames with no data or with data that is not JSON, and handles these events:

EventDataWhat the SDK does
delta{text}Passes text to onDelta.
jobjob infoPasses it to onJob (the page uses it to advance its progress display).
donethe final payload: {job_id, status, charged_credits, output}Keeps it as the result.
pendinga payloadTreated exactly like done. If it carries a job_id and no output, poll that job as in step 4.
error{message, code, job_id}After the stream ends, raises an error with those fields.

If the response is not text/event-stream, it is an ordinary JSON envelope: the SDK returns its data (an idempotent replay returns this way) or raises the error on a non-2xx status. Handle both shapes.

From the final payload the page reads output.output (falling back to the concatenated delta text), truncated and charged_credits. Browsers receive progress ticks rather than deltas from this endpoint, so the page relies on the final payload; if the stream fails part-way, it tries to recover a partial reply from whatever delta text arrived.

# -N turns off buffering so frames print as they arrive
api POST /run-stream -N -H "Idempotency-Key: $KEY" -d "$BODY"

# frames look like:
#   event: job
#   data: {...}
#
#   event: done
#   data: {"job_id":"...","status":"...","charged_credits":...,"output":{"output":"{\"task\":\"triage\",...}"}}

# keep only the final reply text:
api POST /run-stream -N -H "Idempotency-Key: $KEY" -d "$BODY" \
  | awk '/^event:/{ev=$2} /^data:/ && ev=="done"{sub(/^data: */,""); print}' \
  | jq -r '.output.output' | jq .

What the page does that you now do

Or skip all of this and use Phish Desk in the browser: the check, the flags, the verdict floor and the indicator exports are free.