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"
}urlmust usehttporhttpsand resolve to a public internet address; anything else answers 400INVALID_REQUEST. Private, loopback, link-local and multicast addresses are refused, and so are0.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 (NAT6464:ff9b::/96, 6to42002::/16). The address is checked again on every delivery, and redirects are not followed.eventstakes one or more of the four events below; an unknown name answers 400INVALID_REQUEST.secretis 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
| Event | Sent when |
|---|---|
application.created | A candidate applies to one of your listings, or an application is submitted for one (external application) |
application.status_changed | Your company changes an application's status (through the API or on the dashboard) |
job_listing.published | A listing goes live: it is published, or your company's job feed import creates a live listing or makes one live again |
job_listing.closed | A 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.closedfor it. - Deletion. Deleting a live listing (
DELETE /public/v1/job-listings/{id}or on the dashboard) sendsjob_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 (409JOBLISTING_HAS_APPLICATIONS): archive it instead. - Feed imports. Your company's job feed import sends
job_listing.publishedfor each listing it creates live or makes live again, andjob_listing.closedfor 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"
}| Event | Fields |
|---|---|
application.created | applicationId, jobListingId, status; source (EXTERNAL or ANONYMOUS) for external and guest applications, absent when a registered candidate applies |
application.status_changed | applicationId, jobListingId, newStatus |
job_listing.published | jobListingId, companyId, position (the listing's position, as in the listing), publishedAt (ISO 8601 without offset) |
job_listing.closed | jobListingId, 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
2xxanswer counts as delivered. Jobiflow reads only your status code, not your response body; a3xxis not followed and counts as failed, as do4xxand5xx. - 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
| Action | Endpoint |
|---|---|
| List the company's subscriptions | GET /public/v1/webhooks |
| Create a subscription | POST /public/v1/webhooks |
| Change URL, events or active state (fields you leave out stay as they are) | PATCH /public/v1/webhooks/{id} |
| Delete a subscription | DELETE /public/v1/webhooks/{id} |
| Send a test ping | POST /public/v1/webhooks/{id}/test |