Slash Trace docs

API

Webhooks

Instead of polling, be told. A signed POST for every change on a board you track.

Add one

Webhooks are made in the app, at Settings › Developers, on the Ultra plan. Give it an HTTPS URL on a public host and choose the events you want. You can have five. (A development build of Slash Trace also accepts http://localhost, for testing a receiver on your own machine.)

The signing secret is shown once. We keep it encrypted so we can sign with it, but nothing shows it to you again. Store it beside whatever handles the URL; if you lose it, delete the webhook and make another.

There is no endpoint that creates webhooks, deliberately, and for a stronger reason than the one for keys: a key that could register a URL could send every future role to somewhere you never chose, and revoking the key afterwards would not close that URL.

Events

  • job.found — roles appeared on a board. The one most integrations want.
  • job.archived — roles went from a board. Sent only once a role has been missing across several healthy checks, never on a single absence, so a board that failed to load does not close everything on it.
  • board.failed — a board could not be read. So that a receiver which has heard nothing about a board for a while can tell quiet from broken.

One delivery per board per check, however many roles changed. A board that publishes forty roles at once is one POST with forty entries in jobs, not forty POSTs. Past a hundred, the payload carries the first hundred and a truncated count; fetch the rest from list jobs.

The payload

Each job is the same object list jobs returns, so code that already reads the API reads a webhook without a second parser.

job.found
{
  "id": "6a9d1f0c2b7e4a5d8c3f1e20",
  "type": "job.found",
  "createdAt": "2026-08-27T09:14:02.113Z",
  "board": {
    "id": "6a86f2675d98a41a50f70083",
    "company": "Clay",
    "domain": "clay.com",
    "careersUrl": "https://www.clay.com/careers"
  },
  "jobs": [
    {
      "id": "6a8cbf4cde26f685eabcfc0a",
      "boardId": "6a86f2675d98a41a50f70083",
      "company": "Clay",
      "title": "Senior Backend Engineer",
      "location": "New York, NY",
      "department": "Engineering",
      "url": "https://www.clay.com/careers/senior-backend-engineer",
      "status": "open",
      "firstSeenAt": "2026-08-27T09:14:02.113Z",
      "lastSeenAt": "2026-08-27T09:14:02.113Z",
      "closedAt": null
    }
  ]
}
board.failed
{
  "id": "6a9d1f0c2b7e4a5d8c3f1e21",
  "type": "board.failed",
  "createdAt": "2026-08-27T09:14:02.113Z",
  "board": { "id": "…", "company": "Clay", "domain": "clay.com", "careersUrl": "…" },
  "error": "Timed out after 30s"
}

id is the delivery id. A retry sends the same body with the same id, so if you store it you can drop a duplicate without thinking about it.

Headers and signature

Request
POST /hooks/slashtrace HTTP/1.1
Content-Type: application/json
User-Agent: SlashTrace-Webhooks/1.0
X-SlashTrace-Event: job.found
X-SlashTrace-Delivery: 6a9d1f0c2b7e4a5d8c3f1e20
X-SlashTrace-Signature: t=1756286042,v1=5f1c3a…

The signature is an HMAC-SHA256, hex, over the string {t}.{raw body} with your secret, where t is the Unix time the delivery was signed. It is the same scheme Stripe uses, so if you have verified one of theirs you have verified one of ours.

Three things matter when checking it:

  • Sign the raw bytes, not a re-serialised object. Most frameworks parse JSON before your code runs; you need the body as it arrived.
  • Compare in constant time. Every language has a function for this; a plain == leaks the answer a byte at a time.
  • Reject old timestamps. Five minutes is customary. A captured delivery replayed a week later should not verify.
Verifying
import { createHmac, timingSafeEqual } from "node:crypto";

// `raw` is the request body as BYTES, before any JSON parsing.
export function verify(secret, header, raw, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;

  const expected = createHmac("sha256", secret).update(`${t}.${raw}`).digest();
  const given = Buffer.from(parts.v1 ?? "", "hex");
  return given.length === expected.length && timingSafeEqual(given, expected);
}

// Express: keep the raw body for this route.
app.post("/hooks/slashtrace", express.raw({ type: "application/json" }), (req, res) => {
  const signature = req.get("X-SlashTrace-Signature") ?? "";
  if (!verify(process.env.SLASHTRACE_WEBHOOK_SECRET, signature, req.body)) {
    return res.status(401).end();
  }
  const event = JSON.parse(req.body);
  res.status(204).end();          // ack first…
  handle(event);                   // …then do the work
});

Answering

Return any 2xx within ten seconds. Do that before doing the work: a handler that fetches three other things and then answers is a handler that times out on a slow day and gets the same delivery again. Redirects are not followed — a 3xx counts as a failure — because following one would mean POSTing a signed body at a URL you never approved.

Retries

A delivery that does not get a 2xx is tried again after one minute, then five, then thirty, then two hours, then twelve — six attempts over about a day, with the same body and the same delivery id each time. After that it is marked dead and stays visible in the delivery log for a month.

If every delivery to a webhook has failed for three days, the webhook is switched off and the row in Settings says so. Nothing is queued for a disabled webhook; delete it and add it again when the receiver is back.

Testing

Test on a webhook’s row sends a ping — a small body, signed like any other — and reports the status code and how long you took. It is not retried and is not an event a webhook subscribes to. The Recent deliveries list under each row shows the last twenty real ones, with the response code, the error if there was one, and when the next attempt is due.

Where the events come from

A webhook fires when a check of a board finds a change, so it inherits the plan’s cadence: boards on Ultra are checked every hour. The same change also appears in the app’s feed and in the daily email — a webhook is a third way of hearing the one decision, not a second decision.