Webhooks
Webhooks push job lifecycle events to your endpoint in near-real-time instead of forcing you to poll. Available on Starter, Growth, Professional, and Custom plans, with these per-plan caps on active subscriptions:
| Plan | Active webhook subscriptions |
|---|---|
| Starter | 1 |
| Growth | 3 |
| Professional | 5 |
| Custom | agreed per contract |
Why webhooks
Polling the Pull API works well for batch refresh, but it's wasteful when you only want to react to changes. Webhooks let you stay event-driven:
job.created: a job we haven't seen before is publishedjob.updated: a tracked job changed (title, salary, status, etc.)job.closed: a job's listing stopped being livejob.deleted: a job was removed (your store should delete it too)
Webhook deliveries are at-least-once. Your handler must be idempotent. See Dedupe below.
Quickstart
- Open Webhooks in your dashboard and click New Subscription.
- Give it a name, paste your HTTPS endpoint, pick the event types and an optional filter, and submit.
- Copy the signing secret on the one-time reveal screen. Jobven never shows it again.
- Deploy your endpoint with the verifier recipe below.
- Click Send test, from either the create-success modal or the subscription detail page, to fire a deterministic
webhook.testevent at your endpoint. The subscription stays inpending_verificationuntil your endpoint returns2xx, at which point it flips toactiveand starts receiving real job events.
pending_verification and do not receive any real job events until a Send test succeeds. This prevents an empty or broken endpoint from being flooded the moment you save.Subscription lifecycle
| Status | Meaning | How to leave it |
|---|---|---|
pending_verification | Created (or freshly re-enabled). Not in the dispatch pool, so no events are delivered. | Send a test event from the dashboard. A 2xx response flips it to active automatically. |
active | Receiving live events that match the filter, plus every job.closed and job.deleted. | Disable from the dashboard, or hit the failure thresholds (auto-disable). |
disabled_by_user | You paused it. No events are delivered. | Re-enable from the dashboard. This re-routes through pending_verification, so you'll need to send a fresh test first. |
disabled_due_to_failures | Auto-disabled after 50 consecutive failures or 24h with no successful delivery. | Hit Re-enable on the detail page. You'll get the listedAfter value for the gap so you can replay it through the Pull API, and the subscription returns to pending_verification until you send another successful test. |
The verification gate intentionally applies to every state transition that resumes delivery, including re-enable. The endpoint may have changed between when you paused and when you resumed. Re-testing before flipping back to active means a broken endpoint can't silently start consuming quota on retries.
A failed test event leaves the status untouched (still pending or disabled). Test failures do not count toward the auto-disable thresholds and do not bump the streak counters.
Request shape
Every delivery is a POST to your URL with these headers:
POST /your/webhook/path HTTP/1.1
Content-Type: application/json
User-Agent: Jobven-Webhook/1.0
Jobven-Event-Id: 8e3f5c44-1cd1-4a40-bb4f-9d4f95c54c1d
Jobven-Event-Type: job.created
Jobven-Subscription-Id: 7c2f8f7a-d8ab-4d4a-9a09-71b4e6dffb8a
Jobven-Delivery-Attempt: 1
Jobven-Signature: t=1715520000,v1=2e3...
The body is a JSON envelope:
{
"id": "8e3f5c44-1cd1-4a40-bb4f-9d4f95c54c1d",
"type": "job.created",
"createdAt": 1715520000,
"subscriptionId": "7c2f8f7a-d8ab-4d4a-9a09-71b4e6dffb8a",
"deliveryAttempt": 1,
"data": {
"job": { /* … see /docs/reference/jobs for the full shape */ }
}
}
See Event reference for the payload shape of each event type.
Signature verification
Always verify the signature before trusting a payload. Compute HMAC-SHA256(t + "." + raw_body) with your subscription's signing secret and compare to the v1= value in Jobven-Signature. Reject if the timestamp drifts more than 300 seconds (replay protection).
import crypto from 'node:crypto'
const TOLERANCE_SECONDS = 300
export function verifyJobvenWebhook(
rawBody: string,
signatureHeader: string,
secret: string,
): boolean {
const parts = Object.fromEntries(
signatureHeader.split(',').map((p) => p.split('=')),
)
const t = Number(parts.t)
const v1 = parts.v1
if (!t || !v1) return false
const drift = Math.abs(Math.floor(Date.now() / 1000) - t)
if (drift > TOLERANCE_SECONDS) return false
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(v1, 'hex')
if (a.length !== b.length) return false
return crypto.timingSafeEqual(a, b)
}
import hmac
import hashlib
import time
TOLERANCE_SECONDS = 300
def verify_jobven_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
try:
t = int(parts["t"])
v1 = parts["v1"]
except (KeyError, ValueError):
return False
if abs(int(time.time()) - t) > TOLERANCE_SECONDS:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
)
const toleranceSeconds = 300
func VerifyJobvenWebhook(rawBody []byte, signatureHeader, secret string) bool {
var t int64
var v1 string
for _, part := range strings.Split(signatureHeader, ",") {
kv := strings.SplitN(part, "=", 2)
if len(kv) != 2 {
continue
}
switch kv[0] {
case "t":
parsed, err := strconv.ParseInt(kv[1], 10, 64)
if err != nil {
return false
}
t = parsed
case "v1":
v1 = kv[1]
}
}
if t == 0 || v1 == "" {
return false
}
if diff := time.Now().Unix() - t; diff < -toleranceSeconds || diff > toleranceSeconds {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "%d.", t)
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
expectedBytes, err := hex.DecodeString(expected)
if err != nil {
return false
}
gotBytes, err := hex.DecodeString(v1)
if err != nil {
return false
}
return hmac.Equal(expectedBytes, gotBytes)
}
Always use the raw request body as bytes for HMAC. JSON parsing changes whitespace and breaks verification.
Dedupe on Jobven-Event-Id
Webhook deliveries are at-least-once. Persist the Jobven-Event-Id for each event you've processed and skip duplicates. The header is the same UUID across all retry attempts for the same event.
Retries and timeouts
A delivery is considered successful when your endpoint returns a 2xx status within 10 seconds. Otherwise we retry on the following schedule:
| Attempt | Delay from previous |
|---|---|
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 30 minutes |
After the fifth failed attempt we give up. We retry on 5xx, network timeouts, and 429. Other 4xx responses (400, 404, 410, 422) are treated as terminal: fix your endpoint, don't ignore them.
Auto-disable, re-enable, and backfill
To protect both sides, Jobven auto-disables a subscription that hits 50 consecutive failed deliveries or goes 24 hours with no successful delivery. The dashboard surfaces a banner and a re-enable flow.
When you re-enable, Jobven returns backfill.listedAfter, the moment the subscription was disabled. Pull GET /v1/public/jobs?listedAfter= from that value to recover the jobs listed during the gap, and skip ids you already have: the replay runs up to the present, so it overlaps the jobs the resumed stream delivers. It finds new and reopened jobs; to refresh a job you already hold, fetch it by id. See Sync Only New Jobs. The subscription itself returns to pending_verification: send another successful test, and the live webhook stream resumes from that moment. Pull-backfill plus a successful test together cover the entire gap with no replay of stored events.
Quota accounting
- Your plan's monthly allowance is measured in jobs delivered, shared between the Pull API and webhooks. A successful
job.createdorjob.updateddelivery counts one job, because it carries a full job record. There is no daily quota. job.closedandjob.deletedare free, and they keep arriving even when your account is over its limit, so the jobs you already hold do not go stale.- Test events do not count.
- Failed delivery attempts do not count, only successful ones.
- When you are over your limit, new and updated jobs are dropped, not queued, and are not re-sent when the period resets. Backfill the gap through the Pull API with
listedAfter.
Running out of quota
Once you reach your monthly cap, Pull API requests return 429 and job.created and job.updated events are dropped rather than queued. job.closed and job.deleted are free and keep arriving, so the jobs you already hold stay current.
This is not a pause. A dropped event is not stored and is not re-sent when your quota resets or when you upgrade, so any job that is created or updated while you are over the cap will never arrive as a webhook. Unlike auto-disable, there is no backfill window returned for it.
If you go over, treat the gap the same way you would a disable window: replay it through the Pull API with listedAfter set to when you went over, once your quota is available again, and skip ids you already have. See Sync Only New Jobs.
429 response carries Retry-After and X-RateLimit-Reset headers, and a resetAt timestamp in the body, telling you when your quota next resets.Filter scope
Subscriptions accept a structured filter that mirrors a subset of the public Pull API. Allowed keys:
remoteType, experienceLevel, employmentType, industry, skills, language, country, state, city, minSalary, maxSalary, hasVisaSponsorship, hasRelocation.
See Jobs API reference for the semantics of each filter parameter. An empty filter delivers every active job event. It works, but it burns quota aggressively. Always narrow.
job.closed and job.deleted ignore your filter. You receive them whenever a job closes or is removed, even if that job never matched.A closure or deletion is owed to anyone who might be holding the record, and we can't work out who that is from the job's final state: a job can reach you under one set of attributes and close under another. Sending it to everyone is the only way to guarantee nobody is left serving a job that is gone. Match these events on id: two jobs can share an apply URL. If you don't recognise the id, ignore the event; there's nothing to clean up.job.created and job.updated are filtered as described above.Next
- Event reference: payload shape per event type.