Jobiflow LogoDocs

Webhooks

Get an HTTP call when applications and listings change

Webhooks

Webhooks send an HTTP POST to your endpoint when an application arrives or changes status, and when one of your listings is published or closed. They fire for changes made through the API and on the Jobiflow dashboard alike, for your company's job feed imports and when a listing expires.

The webhook endpoints need the webhooks:manage scope.


Registering a Webhook

POST /public/v1/webhooks
X-API-Key: <your_key>
Content-Type: application/json

{
  "url": "https://yourapp.example.com/webhooks/jobiflow",
  "events": ["application.created", "application.status_changed"],
  "secret": "my_webhook_signing_secret"
}
  • url must use http or https and resolve to a public internet address; anything else answers 400 INVALID_REQUEST. Private, loopback, link-local and multicast addresses are refused, and so are 0.0.0.0/8, 100.64.0.0/10 (shared), 198.18.0.0/15 (benchmarking), 240.0.0.0/4 (reserved), fc00::/7 (unique local) and the IPv6 addresses that lead to an IPv4 one (NAT64 64:ff9b::/96, 6to4 2002::/16). The address is checked again on every delivery, and redirects are not followed.
  • events takes one or more of the four events below; an unknown name answers 400 INVALID_REQUEST.
  • secret is optional but strongly recommended: with it, every delivery is signed. It is never returned by the API.

The answer is the subscription: id, companyId, url, events, active, createdAt and updatedAt.


Events

EventSent when
application.createdA candidate applies to one of your listings, or an application is submitted for one (external application)
application.status_changedYour company changes an application's status (through the API or on the dashboard)
job_listing.publishedA listing goes live: it is published, or your company's job feed import creates a live listing or makes one live again
job_listing.closedA live listing comes down: your company closes, archives (archive, or a status change to ARCHIVED) or deletes it, it expires, or your company's job feed no longer contains it

job_listing.closed is sent only when a live (ACTIVE) listing comes down. Archiving or deleting a draft or a listing that has already ended sends nothing. The application statuses set when a listing is closed send no application.status_changed.

Besides closing and archiving through the API or on the dashboard:

  • Expiry. Every night at midnight (Central European time) Jobiflow archives each active listing whose expiry date has passed and sends job_listing.closed for it.
  • Deletion. Deleting a live listing (DELETE /public/v1/job-listings/{id} or on the dashboard) sends job_listing.closed. A listing imported from your company's job feed is archived instead of deleted, with the same event when it was live. Any other listing that has applications cannot be deleted (409 JOBLISTING_HAS_APPLICATIONS): archive it instead.
  • Feed imports. Your company's job feed import sends job_listing.published for each listing it creates live or makes live again, and job_listing.closed for each live listing it archives because the feed no longer contains it.

Each event is still sent only once, so an endpoint that was down misses it. Reconcile your copy of your live listings regularly, for example once a day after midnight, with GET /public/v1/job-listings?status=ACTIVE, reading every page (see Pagination): a listing that is not in the answer is no longer live.


Payload

The request body is the event's own JSON object, with no wrapper around it. The event name is in the X-Jobiflow-Event header.

POST /webhooks/jobiflow
Content-Type: application/json
X-Jobiflow-Event: application.created
X-Jobiflow-Signature: sha256=5d41402abc4b2a76b9719d911017c592...
X-Jobiflow-Signature-256: sha256=5d41402abc4b2a76b9719d911017c592...

{
  "applicationId": "a1b2c3d4-0000-4000-8000-000000000001",
  "jobListingId": "f9e8d7c6-0000-4000-8000-000000000002",
  "status": "ACTIVE",
  "source": "EXTERNAL"
}
EventFields
application.createdapplicationId, jobListingId, status; source (EXTERNAL or ANONYMOUS) for external and guest applications, absent when a registered candidate applies
application.status_changedapplicationId, jobListingId, newStatus
job_listing.publishedjobListingId, companyId, position (the listing's position, as in the listing), publishedAt (ISO 8601 without offset)
job_listing.closedjobListingId, companyId

Read the listing or application through the API when you need more.


Verifying Signatures

When the subscription has a secret, every delivery carries the HMAC-SHA256 of the raw request body, keyed with the secret, as sha256=<hex digest>. The same value is sent in two headers, X-Jobiflow-Signature and X-Jobiflow-Signature-256; check either.

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

function verifyWebhook(rawBody: string, secret: string, header: string): boolean {
  const expected = Buffer.from('sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex'));
  const received = Buffer.from(header);
  return expected.length === received.length && timingSafeEqual(expected, received);
}

Compute the signature over the body exactly as received, before parsing it, and compare with timingSafeEqual (or your language's equivalent).


Delivery

  • Each event is sent once. There are no retries and no delivery log.
  • Only a 2xx answer counts as delivered. Jobiflow reads only your status code, not your response body; a 3xx is not followed and counts as failed, as do 4xx and 5xx.
  • Jobiflow waits up to 5 seconds to connect and up to 10 seconds for your answer, 20 seconds at most in all.
  • An event that is not delivered is not sent again. If your endpoint was down, catch up through the API, for example by listing applications.

Testing a Webhook

POST /public/v1/webhooks/{id}/test
X-API-Key: <your_key>

Jobiflow posts {"event":"test.ping","message":"Webhook test from Jobiflow Public API"} to the subscription's URL with the same headers as a delivery: Content-Type, the signature headers and X-Jobiflow-Event: test.ping. The answer is always 200:

{ "delivered": true, "statusCode": 204 }

or, when the ping failed:

{ "delivered": false, "statusCode": 500, "error": "Webhook endpoint answered HTTP 500" }

statusCode is the status your endpoint answered with, also on a failed ping; it is left out when no answer came. Only a 2xx is delivered: a 3xx is not followed and answers delivered: false with its status.

error is one of Webhook endpoint timed out, Webhook endpoint unreachable, Webhook endpoint answered HTTP <status> or Webhook delivery failed.


Managing Webhooks

ActionEndpoint
List the company's subscriptionsGET /public/v1/webhooks
Create a subscriptionPOST /public/v1/webhooks
Change URL, events or active state (fields you leave out stay as they are)PATCH /public/v1/webhooks/{id}
Delete a subscriptionDELETE /public/v1/webhooks/{id}
Send a test pingPOST /public/v1/webhooks/{id}/test

On this page