Automation & Webhooks

Webhook job data feed

Job events delivered to any HTTPS endpoint, signed and idempotent.

Jobo posts a JSON envelope of job events to the URL you configure: job.upserted for every new or changed job matching your filters, and job.expired when a job closes. Requests are signed with an HMAC-SHA256 secret so you can verify they came from Jobo, and every event carries a deterministic event_id so a repeat can be recognised and dropped. The endpoint only has to answer 2xx quickly; do your processing afterwards. A transformation pipeline can reshape the payload before it is sent, which is what most automation tools want instead of a 20 KB job record.

Sync
Incremental, every 15 min
Deduplication
Upsert on job ID
Pipelines
Supported
Credentials
AES-256 at rest

Connection configuration

The fields Jobo collects to reach your Webhook instance.

urlstringrequired

Absolute HTTPS URL on port 443 that resolves to a public address. Redirects are not followed. Changing it replays the feed to the new endpoint.

http_methodselect

POST (default) or PUT.

batch_sizenumber

Events per request, 1–500 (default 50). Set 1 for one event per request, which is what most no-code catch-hooks expect.

flatten_single_eventboolean

With batch_size 1, send the event object at the top level instead of inside an events array.

signing_secretstring

HMAC secret for X-Jobo-Webhook-Signature. Leave blank and Jobo generates a whsec_ value on save; you can read it back from the feed's settings. Encrypted at rest.

auth_methodselect

none (signature only), bearer, header, or basic — for endpoints that require their own credential on top of the signature.

custom_headersstring

Extra headers, one Name: value per line. Host, Content-Type, Content-Length and X-Jobo-* are reserved.

Webhook config
{
  "url": "https://hooks.example.com/jobo",
  "http_method": "POST",
  "batch_size": 50,
  "flatten_single_event": false,
  "signing_secret": "whsec_••••••••",
  "auth_method": "bearer",
  "auth_token": "••••••••",
  "custom_headers": "X-Tenant: acme"
}

How to sync jobs to Webhook

One-time setup. After this, the feed maintains itself.

  1. 1

    Stand up an HTTPS endpoint that answers 2xx fast

    Jobo POSTs JSON to your URL and treats any 2xx as delivered. Acknowledge first and process afterwards — the request times out after 30 seconds, and a slow handler is retried like an unreachable one. The URL must be HTTPS on port 443 and resolve to a public address; redirects are not followed, so give the exact final URL (a trailing-slash redirect is a failed delivery). Route case matters: /Jobo and /jobo are different paths on most platforms.

    javascript
    // Next.js route handler — app/api/jobo/route.ts
    export async function POST(request: Request) {
      const raw = await request.text();
      // verify the signature (step 2), then enqueue `raw` for processing
      return new Response(null, { status: 202 });
    }
  2. 2

    Verify the signature

    Every request carries X-Jobo-Webhook-Id (the delivery id), X-Jobo-Webhook-Timestamp (unix seconds), and X-Jobo-Webhook-Signature. The signature is v1=<hex> — an HMAC-SHA256 over `${id}.${timestamp}.${rawBody}` using your signing secret. During a secret rotation the header lists two values, current first, so accept any one that matches. Reject timestamps more than five minutes old to bound replay. Also present: X-Jobo-Delivery-Attempt (1–3), X-Jobo-Destination-Id, and User-Agent: Jobo-Webhook/1.0.

    javascript
    import { createHmac, timingSafeEqual } from "node:crypto";
    
    export function verifyJobo(headers: Headers, rawBody: string, secret: string) {
      const id = headers.get("x-jobo-webhook-id") ?? "";
      const ts = headers.get("x-jobo-webhook-timestamp") ?? "";
      const sigs = (headers.get("x-jobo-webhook-signature") ?? "").split(",");
      if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
    
      const expected = createHmac("sha256", secret)
        .update(`${id}.${ts}.${rawBody}`)
        .digest("hex");
      return sigs.some((entry) => {
        const [scheme, hex] = entry.trim().split("=");
        return scheme === "v1" && hex?.length === expected.length &&
          timingSafeEqual(Buffer.from(hex, "hex"), Buffer.from(expected, "hex"));
      });
    }
  3. 3

    Handle the two event types

    The envelope has delivery_id, destination_id, created_at, sync_stamp, and an events array. job.upserted carries the full job (or your pipeline's reshaped document) in data — treat it as an upsert keyed on job_id; there is no separate created event. job.expired carries only job_id: mark the job closed. Expiry events are platform-wide, not filtered to your feed, so ignore job_ids you never received rather than treating them as errors.

    json
    {
      "delivery_id": "23f51963-5eaa-5c16-a0ba-e60926b7b159",
      "destination_id": "3a0b…",
      "created_at": "2026-09-02T07:00:00.0000000Z",
      "sync_stamp": 1788332400,
      "events": [
        { "event_id": "0b6e…", "event": "job.upserted", "job_id": "9f1c…", "occurred_at": "2026-09-02T06:58:11Z",
          "data": { "id": "9f1c…", "title": "Senior Data Engineer", "company": "…", "apply_url": "…" } },
        { "event_id": "c2a9…", "event": "job.expired",  "job_id": "4d7e…", "occurred_at": "2026-09-02T06:59:40Z",
          "data": { "job_id": "4d7e…" } }
      ]
    }
  4. 4

    Dedupe on event_id

    Delivery is at-least-once. Incremental syncs deliberately overlap by five minutes so a job can never fall between two windows, and a retried request re-sends events that may already have landed, so the same job at the same version can arrive more than once. Every event carries an event_id — a name-based UUID derived from your destination, the event type, the job, and its version — that is identical every time that version is sent, whichever request it rides in: store the job.upserted ids you have handled and drop repeats. job.expired needs no dedupe — marking an already-closed job closed is a no-op — and a job that reopens and closes again repeats its expiry event_id, so always apply expiries. The envelope's delivery_id identifies the request; with batch_size 1 it equals the single event's event_id (which is what Zapier dedupes on natively), but with larger batches it changes whenever the batch composition does, so dedupe on event_id. Within one sync, requests are sent one at a time — never concurrently — upserts first, then expiries; across retries and the overlap, order is not guaranteed, so compare updated_at rather than relying on arrival order.

  5. 5

    Send a test event, then launch

    Saving the feed sends one signed connection.test event so you can inspect the exact payload and headers in the portal and check your verifier. The first sync delivers everything matching your filters; from then on a sync runs every 15 minutes with only new, changed, and expired jobs.

Common issues

What actually goes wrong when connecting Webhook, and the fix.

high Every delivery fails with HTTP 404 (for Vercel: 'The page could not be found NOT_FOUND').
The URL Jobo has no longer exists on your deployment — a renamed or removed route, or a redeploy without it. Jobo posts exactly the URL you saved, verbatim. Fix the route (or update the URL in the feed's settings, which sends a test event), then resume the feed. After three consecutive rejected syncs Jobo pauses the feed and emails you, so a dead endpoint is never hammered every 15 minutes.
medium Deliveries fail with HTTP 301, 302, or 308.
Redirects are not followed, on purpose (following one would reach an unvalidated host). Configure the final URL — usually the difference is a trailing slash or http vs https.
medium Signature verification fails.
Sign the raw request body bytes, not a re-serialised JSON object, prefixed with the id and timestamp: `${id}.${timestamp}.${body}`. Compare hex lowercase, and accept any v1 entry in the comma-separated header during a rotation.
low The endpoint receives job.expired events for jobs it never saw.
Expiry events cover every job that closed on the platform in the window, not just your filtered feed. Ignore unknown job_ids; do not fail the request, or the run is retried.
medium The feed was paused (or my endpoint was down) — will I miss events?
A sync only moves the feed's watermark forward when every request in it succeeded, so failed syncs are re-run from the same point and nothing is skipped. A sync that keeps failing on 408/429/5xx or timeouts is retried by itself every 15 minutes and is never paused. Three consecutive syncs rejected with any other 4xx pause the feed and email you; resuming is manual — fix the endpoint, then click Resume feed in the portal. The next sync continues from the old watermark, so every job that changed while paused is delivered once, in its latest state. Expiry events are kept for seven days: after a pause or outage longer than that, run a full resync and reconcile against the jobs it delivers.
low Requests time out or arrive again with X-Jobo-Delivery-Attempt: 2.
Respond within a few seconds and process asynchronously. 408, 429 and 5xx responses are retried up to three times with backoff (Retry-After is honoured, capped at one minute); other 4xx responses are not retried at all.
low The URL is rejected with 'Only absolute HTTPS URLs' or 'disallowed port'.
Only https:// on port 443 to a publicly routable host is accepted; private, loopback, and cloud-metadata addresses are refused. Put a public reverse proxy or a tunnel with a public hostname in front of anything internal.

Best practices

  • Acknowledge with 2xx immediately and process from a queue — the 30-second timeout is for the acknowledgement, not your work.
  • Verify X-Jobo-Webhook-Signature on every request and reject timestamps older than five minutes.
  • Persist handled job.upserted event_ids and drop repeats (overlap is by design); apply every job.expired, since closing a closed job is a no-op.
  • Attach a transformation pipeline to send only the fields your automation needs instead of the full job record.
  • Set batch_size to 1 with flatten_single_event for Zapier, Make and similar catch-hooks; keep 50–500 for your own service.
  • If you rotate the signing secret, keep the previous one configured until every in-flight delivery has been verified.

Start syncing jobs to Webhook

Connect the destination, pick your filters, and let the feed run.