This page describes the ExactHire API that approved integrations, such as the ExactHire app for Zapier, can call. Access is deny-by-default: an OAuth access token can reach only the endpoints listed here, within the scopes the administrator approves on the consent screen. Every other API route returns HTTP 403 to an integration token.
Base URL and request format
-
Base URL:
https://api.exacthire.com/api -
Authentication:
Authorization: Bearer <access token>. The token acts as the ExactHire user who approved the connection, and it is tied to one organization, so no organization id appears in any URL. -
Request and response bodies are JSON unless noted. The token endpoint uses
application/x-www-form-urlencoded.
Response envelope
Almost every endpoint returns HTTP 200 with this envelope. Check code on every call.
{
"code": 200,
"message": null,
"content": { ... }
}
-
Success is
code: 200. An application-level failure iscode: 500with the reason inmessage(occasionally incontent). -
Form validation failures return
code: 500withcontent.children.<field>.errors. -
Real HTTP statuses appear only for 403 (missing scope or permission), unhandled errors, and the OAuth and discovery endpoints, which return standard OAuth JSON.
-
Entity objects use snake_case keys. Webhook payloads and subscription wrappers use camelCase.
Authentication: OAuth 2.0 with PKCE
ExactHire is an OAuth 2.1 authorization server. The integration sends the administrator to the consent page on their organization's own admin address, then exchanges the code for tokens.
|
Item |
Value |
|---|---|
|
Authorization (consent) page |
|
|
Token endpoint |
|
|
Metadata |
|
|
Response type / grants |
|
|
PKCE |
Required. Method |
|
Client authentication |
Confidential clients send the client secret with HTTP Basic or in the form body. |
|
Authorization code |
Single use, valid 5 minutes. |
|
Access token |
Bearer JWT, valid 24 hours ( |
|
Refresh token |
Rotates on every use; valid 30 days from issue or last rotation. Reusing an already-rotated refresh token revokes the whole connection. |
Authorize request parameters: response_type=code, client_id, redirect_uri (exact match against the registered redirect URI), scope (space separated), state, code_challenge and code_challenge_method=S256. The organization must have the integration enabled in its Integrations marketplace.
Token request (application/x-www-form-urlencoded):
grant_type=authorization_code&code=...&redirect_uri=...&code_verifier=...
grant_type=refresh_token&refresh_token=...
Successful token response:
{
"access_token": "<JWT>",
"token_type": "Bearer",
"expires_in": 86400,
"refresh_token": "<opaque>",
"scope": "jobs:read applications:read ..."
}
Errors use standard OAuth JSON, {"error": "...", "error_description": "..."}: invalid_client returns 401, slow_down returns 429, and invalid_request, invalid_grant, unsupported_grant_type and invalid_scope return 400.
Disconnecting. When the connection is revoked in ExactHire, or the approving user is removed or disabled, already-issued access tokens stop working immediately and webhook subscriptions made with that connection are retired. Re-authorizing keeps existing subscriptions active.
Scopes
|
Scope |
Allows |
|---|---|
|
|
Read jobs and job lookup lists (statuses, job types, locations). Required for job webhooks. |
|
|
Search and read applications. Required for application webhooks. |
|
|
Change an application's stage; read the stage list. |
|
|
Add a note to an application. |
|
|
Archive and unarchive applications. |
|
|
Copy an application to another job ad. |
|
|
Add an application to a job; send the applicant thank-you email; read application sources. |
|
|
Create and delete webhook subscriptions; read sample payloads. |
-
Scopes match exactly; one scope never implies another.
-
The approving user must hold the matching ExactHire permission for each scope. Scopes they cannot grant are withheld on the consent screen.
-
A missing scope returns HTTP 403:
The access token lacks the required "<scope>" scope.
Limits and safe retries
-
Write rate limit. Add note, change stage, archive/unarchive, copy and add application are limited to 60 requests per 60 seconds per connection by default. Over the limit returns
code: 500andToo many requests: at most 60 per 60 seconds. Please retry shortly. -
Thank-you email. Limited to 30 requests per 60 seconds.
-
One application per request. Change stage and archive/unarchive accept a single application id per request when called with an integration token.
-
Idempotency.
POST /applications/manualandPOST /applications/send_thank_you_emailaccept an optionalIdempotency-Keyheader (up to 255 characters, remembered for one day, scoped to the connection and endpoint). A repeated completed request returns the stored response withIdempotent-Replay: true. The same key with a different body, or while the first request is still running, is refused. A refused or failed request releases the key.
Webhook subscriptions (REST hooks)
Subscribe an HTTPS endpoint to one or more event types. Scope: webhooks:manage plus the data scope of each event type.
POST /webhooks/subscriptions
|
Field |
Type |
Required |
Notes |
|---|---|---|---|
|
|
string |
yes |
Valid http or https URL, up to 2048 characters. The host must resolve only to public addresses; private, loopback and link-local addresses are rejected. |
|
|
string[] |
yes |
Non-empty, up to 100, each a supported event type (below). |
|
|
string |
no |
Up to 255 characters. |
|
|
boolean |
no |
Default true. |
{
"code": 200,
"message": null,
"content": {
"subscription": {
"id": 17,
"label": "Zapier",
"target_url": "https://...",
"event_types": [
"APPLICATION_SUBMITTED"
],
"is_active": true,
"created_at": "..."
},
"signingSecret": "<64 hex characters>"
}
}
The signingSecret is returned only when the subscription is created. A subscription can be deleted only by the connection that created it.
DELETE /webhooks/subscriptions/{id}
Deactivates the subscription. Success: {"code": 200, "message": null, "content": null}. An unknown id, or one owned by another connection, returns code: 500 and Webhook subscription not found.
GET /webhooks/event-types/{eventType}/samples
Returns one synthetic payload in the delivery shape, for building and testing. Contains no customer data. {"code":200,"content":{"eventType":"...","samples":[ ... ]}}. An event type that is unavailable to integrations, or whose data scope is missing, returns HTTP 403.
Event types
|
Event |
Fires when |
Needs scope |
|---|---|---|
|
|
An application is submitted |
|
|
|
An application's stage changes |
|
|
|
An application is archived |
|
|
|
An application is unarchived |
|
|
|
An application is disqualified |
|
|
|
A disqualified application is reinstated |
|
|
|
A note is added (notes that are admin-only or restricted to named users do not fire) |
|
|
|
A job is created, including drafts |
|
|
|
A job's status changes |
|
Events caused by ExactHire platform staff acting inside an organization are not delivered.
Delivery payload
{
"event": "APPLICATION_SUBMITTED",
"eventId": 1001,
"occurredAt": "2026-01-01T12:00:00+00:00",
"organizationId": 7,
"data": {
"resourceType": "APPLICATION",
"resourceId": 2002,
"description": "Application submitted"
},
"application": {
"id": 2002,
"url": "https://example.admin.exacthire.com/applications/detail/application/2002",
"submittedAt": "2026-01-01T12:00:00+00:00",
"stage": "Applied",
"applicantName": "Jane Sample",
"job": {
"id": 3003,
"title": "Warehouse Associate",
"location": "Indianapolis"
}
},
"job": {
"id": null,
"url": null,
"title": null,
"location": null,
"status": null,
"statusIdentifier": null
}
}
-
Application events fill
applicationand leavejobnull. Job events filljob(id, url, title, location, status, statusIdentifier) and leaveapplicationempty. -
data.resourceTypeisAPPLICATIONorPOSITION.data.descriptionis a fixed sentence for the event type; it never contains note text or a disqualification reason. -
The application block carries the applicant's name only: no email, phone, resume or answers.
urlopens the record in ExactHire and requires the viewer to have access.
Headers and signature
|
Header |
Value |
|---|---|
|
|
|
|
|
|
|
|
The event type |
|
|
Unique delivery id. Delivery is at-least-once, so de-duplicate on it. |
|
|
Unix seconds |
|
|
|
Reject deliveries whose timestamp is stale. Any 2xx response counts as success.
Delivery timing and retries
-
Deliveries are sent in batches about every five minutes, so a first attempt can take up to roughly five minutes after the event.
-
Up to six attempts, with exponential backoff (1, 2, 4, 8 and 16 minutes plus batch timing), roughly 30 minutes in total, then the delivery is marked failed. Connect timeout 10 seconds; total timeout 15 seconds.
-
A delivery is dropped, not retried, when the subscription is gone, the connection was revoked, the approving user was removed or lost permission, or the record is no longer visible to that user.
-
An integration receives only events for records its approving user could read through the API.
Applications
GET /applications
Scope applications:read. Query-string parameters; most structured ones are JSON-encoded.
|
Parameter |
Notes |
|---|---|
|
|
Default 1. |
|
|
Page size, default 100. |
|
|
JSON array of job ids. |
|
|
JSON array of application ids to restrict to. |
|
|
JSON object. Defaults to submitted, not archived, not disqualified. Keys include |
|
|
|
{
"code": 200,
"content": {
"applications": [
{
"id": 2002,
"person_id": 41,
"position": {
"id": 3003,
"title": "..."
},
"applicant_full_name": "...",
"applicant_email": "...",
"submitted_at": "...",
"archived_at": null,
"stage": {
"id": 110
},
"is_disqualified": false
}
],
"total": 1
}
}
Results are capped at 1,000 matches per query; use the default newest-first order with a small limit when polling. Stage is the option id; translate it with the stage list below. Rows contain the applicant's name and email.
GET /applications/{applicationId}
Scope applications:read. Returns content.application (id, submitted_at, archived_at, stage, position, applicant name and email, is_disqualified and related fields). Answers to questions flagged sensitive are withheld from integrations. A missing or out-of-scope id returns code: 500 and Application not found.
POST /applications/manual
Scope applications:create. Adds an application to a job ad. Supports Idempotency-Key.
|
Field |
Type |
Required |
Notes |
|---|---|---|---|
|
|
integer |
yes |
A job ad id (not the job id). Find ids with |
|
|
string |
yes |
|
|
|
string |
yes |
|
|
|
string |
usually |
Valid address. Required unless the organization allows applicants without email. |
|
|
string |
conditional |
At least 5 characters. Required when there is no email and the organization allows that. |
|
|
string |
no |
|
|
|
integer |
no |
Application source option id. Defaults to Career Site. |
|
|
string |
no |
|
|
|
boolean |
no |
Default false. When false and the applicant already has a submitted application on the ad, that application is returned with |
{
"code": 200,
"content": {
"application": {
"id": 2002,
"applicant_name": "...",
"stage": "Applied",
"position_ad": {
"id": 2027,
"title": "..."
}
},
"applications": [
{
"id": 2002
}
],
"user": 41,
"applicant_created": true,
"already_applied": false
}
}
An existing applicant is reused as-is; nothing on them is edited. The application is created, saved and submitted in one step, so a failure leaves nothing behind. This fires APPLICATION_SUBMITTED and the organization's new-application notifications. The thank-you email is not sent by this call; use the thank-you endpoint with the returned user and application ids. Closed jobs are accepted. The response contains no email or phone.
POST /applications/{id}/clone
Scope applications:copy. Copies application {id} to other job ads. The source is unchanged.
|
Field |
Type |
Required |
Notes |
|---|---|---|---|
|
|
array of |
yes |
Job ad ids. |
|
|
integer |
no |
Application source option id; defaults to Career Site. |
|
|
object |
no |
Booleans: |
Success: {"code":200,"content":{"applications":[ ...new applications... ]}}. Each copy starts at stage Applied. If a target ad belongs to the same job as one already processed (including the source's own job), no new application is created for it. Errors: Application not found, Choose the job ad to copy the application to., Job ad not found. (the whole request is refused and nothing is written).
POST /data_point_answers/statuses/applications (change stage)
Scope applications:status.
|
Field |
Type |
Required |
Notes |
|---|---|---|---|
|
|
integer[] |
yes |
One application id per request for integrations. |
|
|
integer[] |
yes |
The target stage option id first. Ids come from |
Success returns the updated application and fires APPLICATION_STATUS_UPDATED. Only submitted, non-disqualified applications can be moved. Errors: Change the status of one application per request., A status is required., Applications required, Invalid application(s), Invalid data point options.
PUT /applications/archived_status (archive and unarchive)
Scope applications:archive.
|
Field |
Type |
Required |
Notes |
|---|---|---|---|
|
|
integer[] |
yes |
One application id per request for integrations. |
|
|
integer |
yes |
|
Fires APPLICATION_ARCHIVED or APPLICATION_UNARCHIVED. Errors: Archive one application per request., No applications found, Status is not valid.
POST /application_notes (add a note)
Scope applications:notes.
|
Field |
Type |
Required |
Notes |
|---|---|---|---|
|
|
integer |
yes |
Application id in the organization. |
|
|
string |
yes |
At least 2 characters. |
|
|
boolean |
no |
Default false. |
|
|
string |
no |
|
|
|
integer[] |
no |
Restrict visibility to these users. |
|
|
integer[] |
no |
|
Success: {"code":200,"content":{"application_note":{ "id": ..., "note": "...", "application_id": ..., "created_at": "..." }}}. Only notes that are not admin-only and have no restricted users fire APPLICATION_NOTE_CREATED.
POST /applications/send_thank_you_email
Scope applications:create. Sends the applicant the organization's thank-you email (or the disqualification email, for a disqualified application). Supports Idempotency-Key.
|
Field |
Type |
Required |
Notes |
|---|---|---|---|
|
|
integer |
yes |
The user id of the applicant (the |
|
|
array of |
yes |
|
|
|
boolean |
no |
An explicit |
|
|
boolean |
no |
Selects the resubmit template. |
The call returns code: 200 even when nothing is sent because the organization's settings suppress it. The email may be sent as a text message when the applicant prefers text and has consented.
Jobs
GET /positions
Scope jobs:read. Lists jobs, not job ads or templates. Parameters: page, limit (default 100), positions (JSON id array), criteria (JSON object; keys include position_status, organization_departments, locations, internal_ids, slug title search, and archive_status) and includeAds=1, which adds position_ads: [{id, title, location, internal_id, career_sites}] to each job.
{
"code": 200,
"content": {
"positions": [
{
"id": 3003,
"title": "...",
"full_location": "...",
"employment_type": "...",
"created_at": "..."
}
],
"positionStatuses": {
"3003": {
"id": 12,
"identifier": "POSITION_STATUS_ACTIVE",
"label": "Active"
}
},
"total": 1
}
}
A job's status is not on the job object. It is in positionStatuses, keyed by job id. Compare on identifier, because option ids differ between environments.
GET /positions/{positionId}
Scope jobs:read. Returns content.position, including position_ads. A missing job returns code: 500 with Job not found. in content.
Lookup lists
All return {"code":200,"content":{"data_point_options":[ {id, identifier, label, ...} ], "total": N}}, with optional slug (name search), page and limit.
|
Endpoint |
Scope |
Notes |
|---|---|---|
|
|
|
Application stages. |
|
|
|
Application sources. |
|
|
|
Job statuses. |
|
|
|
Job types of the caller's organization. |
|
|
|
Locations in the caller's scope. Returns |
Errors
|
Where |
Shape |
|---|---|
|
Missing scope, permission or revoked connection |
HTTP 403 |
|
Application-level failure |
|
|
Validation failure |
|
|
OAuth endpoints |
|
Questions or access to a test organization: contact ExactHire support.