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.
| Event | Fires when | Set up in |
|---|---|---|
job.closed | A 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.matched | A Signal with a webhook destination finds new matches. | The Signal's destination |
alert.test | A 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.
- Enter an
httpsURL and click Add. - 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:
| Button | What 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. |
| Trash | Deletes the endpoint. |
You can do the same with your API key (Authorization: Bearer ...):
| Request | What 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/webhooks | Lists 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-secret | Issues a new secret; the old one stops verifying immediately. |
POST /api/webhooks/{id}/test | Sends one signed sample delivery and returns its HTTP status. |
POST /api/webhooks/{id}/enable | Turns an endpoint back on after it was disabled for failing. |
The number of endpoints depends on your plan:
| Plan | Webhook endpoints |
|---|---|
| Free | Not available |
| Builder, Growth | 5 |
| Scale, Business | 25 |
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"]
}
}| Field | Type | Description |
|---|---|---|
event | string | Always job.closed. |
id | string | The 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. |
timestamp | integer | Unix seconds when this delivery attempt was built. |
data | object | The 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:
| Field | Type | Description |
|---|---|---|
id, job_id | string | The job id search gives you, and that you put in the monitor. Always present. |
internal_id | string | JobsPipe's internal id for the job. Always present. |
native_id | string | null | The source's own id for the posting, or null when the source has none. |
monitor_ids | string[] | Your monitors, delivering to this endpoint, that include the job. Always present. |
source_provider | string | null | Source the job came from, e.g. linkedin. |
closed_reason | string | Why the job closed: gone (the posting page is gone) or closed (the source marks it closed). |
closed_at | string | When JobsPipe recorded the close, ISO-8601 with an offset. |
title | string | Job title. |
company | string | Company name. |
company_domain | string | Company website domain; empty string when unknown. |
country_code | string | ISO 3166-1 alpha-2 country code. |
job_url | string | Link to the posting on the source. |
seniority | string | Seniority label; empty string when unknown. |
posted_at | string | null | When the source says the job was posted, UTC YYYY-MM-DD HH:MM:SS. |
occupation_code | string | ISCO occupation code; empty string when unknown. |
isic_division | string | ISIC industry division; empty string when unknown. |
skills | string[] | 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
}
]
}| Field | Type | Description |
|---|---|---|
event | string | Always alert.matched. |
alert_id | string | The Signal's id. |
mode | string | jobs (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). |
truncated | boolean | true 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. |
timestamp | integer | Unix seconds when the batch was built. Retries resend the same body, so this does not change between attempts. |
data | array | The 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.
| Field | Type | Description |
|---|---|---|
id | string | Job id. |
job_title | string | null | Job title. |
company | string | null | Company name. |
company_domain | string | null | Company website domain. |
final_url | null | Always null. Use url. |
url | string | null | The posting's URL on its source. Signal emails and Slack messages link to it. |
discovered_at | string | null | When JobsPipe first ingested the posting, ISO-8601 with an offset. |
seniority | string | null | Seniority label. |
location | string | null | Short location, e.g. Austin, TX. |
country_code | string | null | ISO 3166-1 alpha-2 country code. |
sources | string[] | 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_arrangement | string | null | remote, 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. |
remote | boolean | null | true when the posting is remote, false when it is not, null when unknown. |
date_posted | string | null | When the employer posted it, ISO-8601 with an offset, when known. discovered_at is when JobsPipe first saw it. |
skills | string[] | Up to 12 technology slugs. |
industry | string | null | Industry label, or the ISIC division code when there is no label. |
employer_type | string | null | Employer type, e.g. employer or agency. |
employee_count | number | null | Company headcount. |
company_description | string | null | Plain-text company description, cut at 240 characters. |
description_excerpt | string | null | Plain-text start of the posting, cut at 400 characters. |
company_industry | string | null | The company's industry, when known. industry above is classified from the posting. |
company_headcount | number | null | The company's own headcount, when known. employee_count is the count published with the posting. |
headcount_low | number | null | Lower bound of the company's headcount band, when only a band is known. |
headcount_high | number | null | Upper bound of that band. |
revenue_usd | number | null | Estimated annual revenue in US dollars, when known. |
revenue_low_usd | number | null | Lower bound of the company's revenue band. |
revenue_high_usd | number | null | Upper 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"
}
]
}| Field | Type | Description |
|---|---|---|
company.id | string | The company key, the same id company search returns and GET /v1/companies/{key}/technologies takes. |
company.name | string | Company name. |
company.domain | string | null | The company's domain when confirmed; otherwise null. Its JobsPipe company page is https://jobspipe.dev/hiring/<domain>. |
company.url | string | null | The company's website, only when domain is set. |
technology.slug | string | The technology's slug, one of the Signal's technology_slug_or. |
technology.name | string | Its display name. |
tier | string | Evidence tier when the match was found: confirmed or likely. |
jobs | number | Postings at this company naming the technology so far. |
first_seen | string | Date (YYYY-MM-DD) of the first posting naming the technology. |
last_seen | string | Date of the latest posting naming it. |
adopted_on | string | The 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:
| Header | Contents |
|---|---|
Content-Type | application/json |
X-JobsPipe-Signature | Hex HMAC-SHA256 of `${timestamp}.${rawBody}` with your secret |
X-JobsPipe-Timestamp | Unix 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 attempt | Next attempt in |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 6 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 withPOST /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.