Webhooks

JobsPipe webhooks POST signed JSON to your endpoint - job.closed when a job in one of your monitors closes, alert.matched for Signal matches, HMAC-SHA256 verification, retry schedule.

JobsPipe webhooks POST signed events to your endpoint as they happen, so you don't have to poll.

EventFires whenSet up in
job.closedA job in one of your monitors closes.A webhook endpoint (Delivery methods → Webhooks, or POST /api/webhooks) plus a monitor that delivers to it
alert.matchedA Signal with a webhook destination finds new matches.The Signal's destination
alert.testA Signal webhook destination is created. A signed test with an empty data array, so a wrong URL shows up right away.The Signal's destination

Webhooks are available from the Builder package up (not on Free or Seeker). URLs must be public https URLs: http, localhost, loopback, private-network (10.x, 172.16-31.x, 192.168.x), link-local (169.254.x, fe80:) and .local or .internal hosts are rejected.

To pick up new postings, save a search as a Signal or poll jobs search, as in Build a daily new-jobs feed.

job.closed

A monitor lists the jobs you want to follow and names one of your endpoints. When a job in an active monitor closes, that endpoint receives job.closed. Jobs in no monitor produce no event.

Only verified closes are sent: gone (the posting page is gone) and closed (the source marks it closed). A job that only stopped appearing in its source's feed shows as closed in search with closed_reason: "stale", but no webhook fires. Events cost no credits.

Manage endpoints

In the dashboard, open Webhooks under Delivery methods in the sidebar.

  1. Enter an https URL and click Add.
  2. Copy the signing secret now - it starts with whsec_ and is shown only once. Click I have saved it to dismiss it.

Each endpoint shows its URL, its last delivery and its failure count, with three buttons:

ButtonWhat it does
Send (paper plane)Sends one signed sample job.closed delivery and shows the HTTP status. Test sends are not retried.
Refresh (arrows)Rotates the signing secret. The new secret is shown once and the old one stops verifying immediately.
TrashDeletes the endpoint.

You can do the same with your API key (Authorization: Bearer ...):

RequestWhat it does
POST /api/webhooks {"url": "https://...", "enabledEvents": ["job.closed"]}Creates an endpoint. enabledEvents is optional and defaults to every event (job.closed today). The response carries the full signingSecret, once.
GET /api/webhooksLists endpoints with enabledEvents, enabled, disabledReason, failureCount and consecutiveFailures.
PATCH /api/webhooks/{id} {"enabledEvents": [...]}Changes which events the endpoint receives.
DELETE /api/webhooks/{id}Deletes an endpoint, and the monitors that deliver to it.
POST /api/webhooks/{id}/rotate-secretIssues a new secret; the old one stops verifying immediately.
POST /api/webhooks/{id}/testSends one signed sample delivery and returns its HTTP status.
POST /api/webhooks/{id}/enableTurns an endpoint back on after it was disabled for failing.

The number of endpoints depends on your plan:

PlanWebhook endpoints
FreeNot available
Builder, Growth5
Scale, Business25

Payload

{
  "event": "job.closed",
  "id": "4464825573",
  "timestamp": 1789374765,
  "data": {
    "id": "4464825573",
    "job_id": "4464825573",
    "internal_id": "a1b2c3d4e5f60718",
    "native_id": "4464825573",
    "monitor_ids": ["7f3c1a52-8a3e-4c1e-9f0a-2d6b4e1c9a77"],
    "source_provider": "linkedin",
    "closed_reason": "gone",
    "closed_at": "2026-09-14T08:12:45.123456+00:00",
    "title": "Senior Backend Engineer",
    "company": "Acme Inc",
    "company_domain": "acme.com",
    "country_code": "US",
    "job_url": "https://www.linkedin.com/jobs/view/1234567890",
    "seniority": "senior",
    "posted_at": "2026-08-28 14:03:11",
    "occupation_code": "2512",
    "isic_division": "62",
    "skills": ["go", "postgresql", "kubernetes"]
  }
}
FieldTypeDescription
eventstringAlways job.closed.
idstringThe job id as search returned it to you, not a separate event id. The same as data.job_id, and the same on every retry, so use it to deduplicate.
timestampintegerUnix seconds when this delivery attempt was built.
dataobjectThe closed job, described below.

data is a short summary of the job, not the full job schema. The field names differ from search results, for example title rather than job_title and job_url rather than url:

FieldTypeDescription
id, job_idstringThe job id search gives you, and that you put in the monitor. Always present.
internal_idstringJobsPipe's internal id for the job. Always present.
native_idstring | nullThe source's own id for the posting, or null when the source has none.
monitor_idsstring[]Your monitors, delivering to this endpoint, that include the job. Always present.
source_providerstring | nullSource the job came from, e.g. linkedin.
closed_reasonstringWhy the job closed: gone (the posting page is gone) or closed (the source marks it closed).
closed_atstringWhen JobsPipe recorded the close, ISO-8601 with an offset.
titlestringJob title.
companystringCompany name.
company_domainstringCompany website domain; empty string when unknown.
country_codestringISO 3166-1 alpha-2 country code.
job_urlstringLink to the posting on the source.
senioritystringSeniority label; empty string when unknown.
posted_atstring | nullWhen the source says the job was posted, UTC YYYY-MM-DD HH:MM:SS.
occupation_codestringISCO occupation code; empty string when unknown.
isic_divisionstringISIC industry division; empty string when unknown.
skillsstring[]Skill slugs; empty array when none.

id, job_id, internal_id, monitor_ids and closed_reason are always present. title through skills can be missing. A retry resends the first attempt's body unchanged.

Close events are best effort; don't treat them as a complete record of closures.

alert.matched

A Signal with a webhook destination posts alert.matched when it finds new matches: once per check for As soon as they appear (instant) destinations, or once a day for Daily digest destinations.

Signing secret

Each Signal webhook destination gets its own whsec_ signing secret, used to sign every alert.matched delivery.

The secret is shown once, right after you create the Signal. Copy it before you click I have saved it. It can't be shown again or rotated; to get a new one, delete the Signal and create it again.

Signals with a Slack destination post unsigned JSON to the Slack incoming webhook URL.

Payload

{
  "event": "alert.matched",
  "alert_id": "6f1c2b9e-3d4a-4e8b-9a7c-2f5d8e1b0c34",
  "mode": "companies",
  "truncated": false,
  "timestamp": 1789374765,
  "data": [
    {
      "id": "a1b2c3d4e5f60718",
      "job_title": "Head of Finance",
      "company": "Acme Inc",
      "company_domain": "acme.com",
      "final_url": null,
      "url": "https://jobs.ashbyhq.com/acme/1f2e3d4c-head-of-finance",
      "discovered_at": "2026-09-14T05:41:07+00:00",
      "seniority": "lead",
      "location": "Austin, TX",
      "country_code": "US",
      "sources": ["ashby"],
      "work_arrangement": "hybrid",
      "remote": false,
      "date_posted": "2026-09-13T22:10:00+00:00",
      "skills": ["netsuite", "excel"],
      "industry": "Computer programming, consultancy and related activities",
      "employer_type": "employer",
      "employee_count": 180,
      "company_description": "Acme builds billing software for mid-market SaaS companies.",
      "description_excerpt": "We are hiring our first Head of Finance to own planning, reporting and our NetSuite rollout.",
      "company_industry": "Financial Software",
      "company_headcount": 180,
      "headcount_low": 100,
      "headcount_high": 249,
      "revenue_usd": 22360680,
      "revenue_low_usd": 10000000,
      "revenue_high_usd": 50000000
    }
  ]
}
FieldTypeDescription
eventstringAlways alert.matched.
alert_idstringThe Signal's id.
modestringjobs (one item per new posting), companies (one item per company, the first time it matches) or technology_adoption (one item per company and technology, the first time the company starts hiring for it; beta).
truncatedbooleantrue when the check hit its limit of 1,000 postings (1,000 adoptions in technology_adoption mode), so there may be more matches than were found. Daily digests always send false.
timestampintegerUnix seconds when the batch was built. Retries resend the same body, so this does not change between attempts.
dataarrayThe new matches, at most 25 per delivery. A check that finds more sends several deliveries.

Each item in data is a snapshot of one posting. In companies mode it is the first matching posting found for that company.

FieldTypeDescription
idstringJob id.
job_titlestring | nullJob title.
companystring | nullCompany name.
company_domainstring | nullCompany website domain.
final_urlnullAlways null. Use url.
urlstring | nullThe posting's URL on its source. Signal emails and Slack messages link to it.
discovered_atstring | nullWhen JobsPipe first ingested the posting, ISO-8601 with an offset.
senioritystring | nullSeniority label.
locationstring | nullShort location, e.g. Austin, TX.
country_codestring | nullISO 3166-1 alpha-2 country code.
sourcesstring[]Where the posting was seen, e.g. ["greenhouse"] or ["greenhouse", "linkedin"]; the same values source_or accepts. Hide aggregator reposts by checking for linkedin or indeed here.
work_arrangementstring | nullremote, hybrid or onsite, or null when the posting does not say. About a third of ATS postings are null, so a receiver that wants remote-eligible roles can keep null ones and judge them by location.
remoteboolean | nulltrue when the posting is remote, false when it is not, null when unknown.
date_postedstring | nullWhen the employer posted it, ISO-8601 with an offset, when known. discovered_at is when JobsPipe first saw it.
skillsstring[]Up to 12 technology slugs.
industrystring | nullIndustry label, or the ISIC division code when there is no label.
employer_typestring | nullEmployer type, e.g. employer or agency.
employee_countnumber | nullCompany headcount.
company_descriptionstring | nullPlain-text company description, cut at 240 characters.
description_excerptstring | nullPlain-text start of the posting, cut at 400 characters.
company_industrystring | nullThe company's industry, when known. industry above is classified from the posting.
company_headcountnumber | nullThe company's own headcount, when known. employee_count is the count published with the posting.
headcount_lownumber | nullLower bound of the company's headcount band, when only a band is known.
headcount_highnumber | nullUpper bound of that band.
revenue_usdnumber | nullEstimated annual revenue in US dollars, when known.
revenue_low_usdnumber | nullLower bound of the company's revenue band.
revenue_high_usdnumber | nullUpper bound of that band.

When a Signal has an intent, every item has passed the AI relevance check. If the check cannot run, nothing is delivered and the same postings are checked again on the next run.

A Signal with a size filter also leaves out companies whose known size is outside it, even when the posting carries no size.

technology_adoption items

In technology_adoption mode (beta, see Signals → Technology adoption) each item in data is one company starting to hire for one technology instead of a posting:

{
  "event": "alert.matched",
  "alert_id": "6f1c2b9e-3d4a-4e8b-9a7c-2f5d8e1b0c34",
  "mode": "technology_adoption",
  "truncated": false,
  "timestamp": 1789374765,
  "data": [
    {
      "company": {
        "id": "9291827030970451911",
        "name": "Acme Inc",
        "domain": "acme.com",
        "url": "https://acme.com"
      },
      "technology": { "slug": "snowflake", "name": "Snowflake" },
      "tier": "confirmed",
      "jobs": 3,
      "first_seen": "2026-09-22",
      "last_seen": "2026-09-24",
      "adopted_on": "2026-09-22"
    }
  ]
}
FieldTypeDescription
company.idstringThe company key, the same id company search returns and GET /v1/companies/{key}/technologies takes.
company.namestringCompany name.
company.domainstring | nullThe company's domain when confirmed; otherwise null. Its JobsPipe company page is https://jobspipe.dev/hiring/<domain>.
company.urlstring | nullThe company's website, only when domain is set.
technology.slugstringThe technology's slug, one of the Signal's technology_slug_or.
technology.namestringIts display name.
tierstringEvidence tier when the match was found: confirmed or likely.
jobsnumberPostings at this company naming the technology so far.
first_seenstringDate (YYYY-MM-DD) of the first posting naming the technology.
last_seenstringDate of the latest posting naming it.
adopted_onstringThe adoption date: the first posting naming the technology, the same as first_seen.

Verifying signatures

job.closed and alert.matched deliveries carry the same headers and are signed the same way:

HeaderContents
Content-Typeapplication/json
X-JobsPipe-SignatureHex HMAC-SHA256 of `${timestamp}.${rawBody}` with your secret
X-JobsPipe-TimestampUnix seconds when this attempt was signed

Recompute the HMAC over the X-JobsPipe-Timestamp header, a dot, and the raw request body, and compare in constant time. Every attempt, including every retry, is signed with a fresh timestamp, so you can reject deliveries whose header timestamp is more than five minutes old to guard against replay. Check the header, not the body's timestamp: an alert.matched retry keeps its original body timestamp.

import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody: string, signature: string, timestamp: string, secret: string) {
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");
  return (
    expected.length === signature.length &&
    timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
  );
}

Delivery and retries

Respond with any 2xx within 10 seconds to acknowledge. Redirects are not followed: a 3xx counts as a failed attempt, so register the final URL. Anything other than a 2xx, a timeout or a connection error is retried.

Each event gets up to 6 attempts. After a failed attempt, the next one is scheduled after:

After attemptNext attempt in
11 minute
25 minutes
330 minutes
42 hours
56 hours

An attempt can land up to a minute after its scheduled time. The last attempt comes about 8.5 hours after the first; if it fails, the event is dropped.

What happens next differs by event:

  • job.closed: when 20 events in a row fail all 6 attempts, the endpoint is disabled (enabled: false, disabledReason: "consecutive_failures"). Turn it back on in the dashboard or with POST /api/webhooks/{id}/enable. Closes that happen while it is disabled are not replayed.
  • alert.matched: when a delivery fails all 6 attempts and the destination accepted nothing in the previous 24 hours, the destination is paused. The Signal's owner gets an email, and the Signals page shows a Resume button. Matches found while paused are not delivered.

On this page