Developer documentation · API version v1 · Last updated October 28, 2026
Bússola API and webhooks
The Bússola REST API lets your systems read and create candidates, move them through the hiring pipeline, and read jobs and team members. Webhooks tell your systems when something happens in Bússola. The API and webhooks are included in the Growth and Pro plans.
Resumo em português. Esta documentação para desenvolvedores está em inglês, como é o padrão. A API da Bússola lê e cria candidatos, muda a etapa no pipeline e lê vagas e equipe; os webhooks avisam outro sistema quando algo acontece (candidato criado, etapa mudada, entrevista marcada, proposta assinada, contratação e outros). O administrador cria as chaves em Configurações, API, webhooks e Zapier, escolhe as permissões e vê a chave uma única vez. Limite de 60 pedidos por minuto por chave. Candidatos criados pela API não recebem ligação da IA sem a declaração de consentimento, e reprovar exige uma pessoa. Para conectar sem programar, use o app da Bússola no Zapier.
Resumen en español. Esta documentación para desarrolladores está en inglés, como es habitual. La API de Bússola lee y crea candidatos, cambia la etapa en el pipeline y lee vacantes y equipo; los webhooks avisan a otro sistema cuando algo pasa (candidato creado, cambio de etapa, entrevista agendada, oferta firmada, contratación y más). El administrador crea las claves en Configuración, API, webhooks y Zapier, elige los permisos y ve la clave una sola vez. Límite de 60 solicitudes por minuto por clave. Los candidatos creados por la API no reciben llamadas de la IA sin la declaración de consentimiento, y rechazar requiere una persona. Para conectar sin programar, usa la app de Bússola en Zapier.
Authentication and keys
A company admin creates keys in Settings, API, webhooks and Zapier. Each key belongs to one company and has scopes. The full key is shown once; Bússola stores only a hash. Revoke a key at any time and it stops working right away.
Send the key in the Authorization header on every request. Never put the key in the URL: requests with ?api_key= are refused.
curl https://YOUR-BUSSOLA-ADDRESS/api/v1/me \
-H "Authorization: Bearer bsk_1a2b3c4d_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
| Scope | Allows |
|---|---|
candidates:read | List and read candidates and their applications |
candidates:write | Create candidates and change the stage (except rejection) |
candidates:reject | Move to rejected, only when the admin also turns on Allow rejecting candidates through the API |
jobs:read | List and read jobs |
team:read | List and read team members |
webhooks | Create, list and delete the key's own webhook subscriptions (used by Zapier). Each event also needs candidates:read or team:read, see Webhooks |
GET /api/v1/me returns the company, the key name, prefix and scopes. Use it to test a key.
Requests, errors and limits
- Base path:
/api/v1. JSON in and out, UTF-8. Dates are ISO 8601 in UTC, like2026-10-28T14:00:00Z. - Rate limit: 60 requests per minute per key. Every response has
X-RateLimit-LimitandX-RateLimit-Remaining. Over the limit you get429withRetry-Afterin seconds. - CORS is off: call the API from your server, not from a browser page.
- Paths are exact and lowercase. Only the endpoints in this document exist: anything else returns
404 not_found. Encoded slashes, dots or backslashes (%2F,%2E,%5C) and..return400 invalid_path. - Errors always look like this:
{"error": {"code": "insufficient_scope", "message": "This key does not have the candidates:read scope."}}
| Status | Codes |
|---|---|
| 400 | invalid_limit, invalid_cursor, invalid_stage, invalid_date, invalid_body, invalid_consent_attestation, key_in_url, invalid_path |
| 401 | unauthorized (missing, wrong or revoked key) |
| 403 | insufficient_scope, plan_required, rejection_requires_human, human_review_required |
| 404 | not_found, application_not_found |
| 409 | duplicate (with candidate_id), ambiguous_application, no_application |
| 422 | missing_name, missing_contact, invalid_email, invalid_phone, invalid_language, invalid_job_id, invalid_url, url_not_allowed, invalid_events |
| 429 | rate_limited |
Pagination
Lists return newest first. Use limit (1 to 100, default 25) and pass next_cursor from the previous page as cursor until has_more is false.
GET /api/v1/candidates?limit=50
{"data": [ ... ], "has_more": true, "next_cursor": "aTo0MjE"}
GET /api/v1/candidates?limit=50&cursor=aTo0MjE
Candidates
GET /api/v1/candidates filters: email, job_id, stage, created_after.
GET /api/v1/candidates/{id}
POST /api/v1/candidates
{
"id": 421,
"name": "Maria Silva",
"email": "maria@example.com",
"phone": "+18605550100",
"location": "Hartford",
"language": "en",
"source": "careers_page",
"archived": false,
"ai_calls_consent": true,
"created_at": "2026-10-28T14:00:00Z",
"applications": [
{"id": 977, "job_id": 12, "job_title": "House Cleaning Technician", "stage": "interview",
"withdrawn": false, "created_at": "2026-10-28T14:00:00Z", "updated_at": "2026-10-29T09:10:00Z"}
]
}
Stages: applied, assessment, interview, offer, hired, rejected.
Create a candidate
curl -X POST https://YOUR-BUSSOLA-ADDRESS/api/v1/candidates \
-H "Authorization: Bearer $BUSSOLA_KEY" -H "Content-Type: application/json" \
-d '{"name": "Maria Silva", "email": "maria@example.com", "phone": "+1 860 555 0100",
"location": "Hartford", "job_id": 12, "language": "en", "note": "Met at the job fair"}'
name is required, plus email or phone (with the country code). A candidate with the same email or phone in your company returns 409 duplicate with candidate_id. Response: 201 with the candidate.
Consent for AI calls (TCPA)
Candidates created through the API never get automated or AI calls and texts unless the request includes the consent attestation, exactly like adding a candidate by hand in Bússola. Send it only when you really have the person's prior express written consent. Bússola records the text, version, key, time and IP address.
"consent_attestation": {
"attested": true,
"text": "I have this person's prior express written consent to receive automated/AI calls and texts about this application."
}
The text must match one of these exactly:
- English: I have this person's prior express written consent to receive automated/AI calls and texts about this application.
- Português: Tenho o consentimento prévio, expresso e por escrito desta pessoa para receber ligações e mensagens de texto automatizadas ou com IA sobre esta candidatura.
- Español: Tengo el consentimiento previo, expreso y por escrito de esta persona para recibir llamadas y mensajes de texto automatizados o con IA sobre esta postulación.
Without it, the candidate is created normally and a manager can send a consent request from Bússola. The calling rules of the company still apply either way.
Change the stage
PATCH /api/v1/candidates/{id}/stage
{"stage": "interview", "application_id": 977}
Send application_id or job_id when the candidate has more than one application (otherwise 409 ambiguous_application). Moving to hired adds the person to Team. The change shows in the candidate timeline as made by the key.
Rejection needs a person. Moving to rejected returns 403 rejection_requires_human unless the key has the candidates:reject scope and an admin turned on Allow rejecting candidates through the API. Even then, an application with an AI screening result or a match waiting for human review returns 403 human_review_required: only a person can reject it in Bússola. An optional reason is stored with an allowed rejection. No email is sent to the candidate by the API.
Jobs
GET /api/v1/jobs (filter status: open, paused, closed) and GET /api/v1/jobs/{id}
{"id": 12, "title": "House Cleaning Technician", "status": "open", "location": "Hartford", "city": "Hartford",
"state": "CT", "remote": false, "employment_type": "FULL_TIME", "salary_min": 18, "salary_max": 22,
"salary_unit": "HOUR", "currency": "USD", "language": "en", "position_id": 3,
"created_at": "2026-10-01T12:00:00Z", "updated_at": "2026-10-20T08:30:00Z"}
Team
GET /api/v1/team (filter status: onboarding, active, leaving, terminated) and GET /api/v1/team/{id}
{"id": 55, "name": "Maria", "legal_name": "Maria Silva", "status": "active", "employment_type": "w2",
"job_title": "Cleaning Technician", "department": "Residential", "location": "Hartford",
"work_email": "maria@yourcompany.com", "work_phone": null, "start_date": "2026-11-03", "end_date": null,
"manager_id": 7, "candidate_id": 421, "created_at": "2026-10-30T15:00:00Z"}
Webhooks
Add endpoints in Settings, API, webhooks and Zapier or through the API with the webhooks scope. Each endpoint chooses its events and gets its own signing secret (whsec_...), shown once. Up to 10 endpoints per company.
- Address:
httpsonly, a public domain name (no IP addresses, localhost or internal names), port 443 or 1024 and up. Every address the name resolves to must be public, checked again on each delivery. - Each delivery is a
POSTwith a JSON body, a 10 second timeout and no redirects. Answer with any2xxquickly and do the work later. - Send test event in Settings sends a
testevent. The delivery log shows the status, the response code and the attempts.
GET /api/v1/webhooks · POST /api/v1/webhooks · DELETE /api/v1/webhooks/{id} · GET /api/v1/webhooks/events
POST /api/v1/webhooks
{"url": "https://example.com/bussola/webhook", "events": ["candidate.created", "candidate.stage_changed"], "description": "CRM"}
201 {"id": 8, "url": "https://example.com/bussola/webhook", "events": [...], "active": true, "origin": "api",
"secret": "whsec_..."}
Events need the scope of their data. Events carry candidate or team member details, so a key with only the webhooks scope cannot subscribe to them. Creating a subscription through the API returns 403 insufficient_scope for any event the key may not read:
| Events | Scope also required |
|---|---|
candidate.created, candidate.stage_changed, screening.completed, interview.booked, interview.canceled, offer.sent, offer.signed | candidates:read |
employee.hired, employee.terminated, training.completed, review.acknowledged | team:read |
"events": ["*"] subscribes to every event the key is allowed to receive (and only those). GET /api/v1/webhooks/events lists all events, the ones this key may use (allowed) and the scope each one needs (required_scopes). Bússola checks again before each delivery: if the key is revoked or loses the scope, those events stop. Revoking a key turns off the subscriptions it created and cancels their pending deliveries.
A key sees and deletes only the subscriptions it created itself. Endpoints added in Settings and those created by other keys are not listed and return 404.
When a candidate or team member is anonymized or deleted, their pending deliveries are canceled and the stored copies in the delivery log keep only the event type, time and ids. Those deliveries cannot be resent.
Use "events": ["*"] for every event your key allows. If your endpoint answers 410 Gone, a subscription created through the API is deleted (this is how Zapier unsubscribes); one created in Settings is turned off.
Event types and payloads
Headers: Bussola-Event (type), Bussola-Event-Id (same for every endpoint and retry, use it to ignore duplicates), Bussola-Delivery, Bussola-Signature. Body:
{
"id": "evt_5f1c0e9a2b7d4c3e8f6a1b2c",
"type": "candidate.stage_changed",
"api_version": "v1",
"created_at": "2026-10-28T14:00:05Z",
"company": {"name": "BHS CT", "slug": "bhs-ct"},
"data": {
"candidate": {"id": 421, "name": "Maria Silva", "email": "maria@example.com", "phone": "+18605550100"},
"application": {"id": 977, "job_id": 12, "job_title": "House Cleaning Technician", "stage": "interview"},
"from_stage": "assessment",
"to_stage": "interview"
}
}
| Event | When | data |
|---|---|---|
candidate.created | A candidate is created (careers page, Indeed, by hand, API, inbound call) | candidate (id, name, email, phone, location, source, created_at), applications |
candidate.stage_changed | Any stage change (manager, automation, scheduling, offer, API) | candidate, application, from_stage, to_stage |
screening.completed | A voice or form screening is scored | candidate, application, screening (channel voice or form, completed_at). Scores stay in Bússola for a person to review. |
interview.booked | An interview is booked or rescheduled | candidate, application, interview (id, start, end, rescheduled) |
interview.canceled | An interview is canceled | candidate, application, interview (id, start, end) |
offer.sent | An offer and onboarding are sent | candidate, offer (id, employment_type, job_title, start_date, application_id). No pay. |
offer.signed | The candidate signs the offer letter | Same as offer.sent |
employee.hired | A person is added to Team (hired) | employee (id, name, employment_type, status, start_date, job_title, location, work_email, candidate_id) |
employee.terminated | A team member's status becomes terminated | employee (id, name, end_date). No reason or type. |
training.completed | A training assignment is completed | employee (id, name), course (id, title), completed_at |
review.acknowledged | A team member acknowledges a performance review | employee (id, name), review (id, cycle_id, milestone, acknowledged_at). No ratings or comments. |
test | Send test event | message, endpoint_id |
Verify the signature
Every delivery has Bussola-Signature: t=1761660005,v1=5257a8.... v1 is the hex HMAC-SHA256 of "{t}.{raw body}" with your endpoint secret. Compare in constant time and reject timestamps older than 5 minutes (replay protection). Always use the raw body bytes, before any JSON parsing.
Python
import hashlib, hmac, time
def verify(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
try:
parts = dict(item.split("=", 1) for item in header.split(","))
timestamp, received = int(parts["t"]), parts["v1"]
except (ValueError, KeyError):
return False
if abs(time.time() - timestamp) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received)
# Flask example
# @app.post("/bussola/webhook")
# def hook():
# if not verify(SECRET, request.get_data(), request.headers.get("Bussola-Signature", "")):
# abort(400)
# event = request.get_json()
# return "", 204
Node.js
const crypto = require("crypto");
function verify(secret, rawBody, header, tolerance = 300) {
const parts = Object.fromEntries(String(header || "").split(",").map((p) => p.split("=", 2)));
const timestamp = Number(parts.t);
if (!timestamp || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > tolerance) return false;
const expected = crypto.createHmac("sha256", secret)
.update(`${timestamp}.`).update(rawBody).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express example: keep the raw body
// app.post("/bussola/webhook", express.raw({ type: "application/json" }), (req, res) => {
// if (!verify(process.env.BUSSOLA_SECRET, req.body, req.get("Bussola-Signature"))) return res.sendStatus(400);
// const event = JSON.parse(req.body);
// res.sendStatus(204);
// });
Retries and auto disable
Any answer other than 2xx (or no answer) is retried with exponential backoff: after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours (8 attempts over about 2 days). Each retry has a fresh timestamp and signature and the same Bussola-Event-Id. After 20 failed attempts in a row over at least 24 hours the endpoint is turned off, pending deliveries are canceled and the company admins get an email. Turn it back on in Settings; failed deliveries can be resent from the log. The log keeps 30 days.
Zapier
The Bússola app on Zapier uses this API: triggers for every event above (REST hooks on /api/v1/webhooks), the actions Create candidate and Move stage, and the search Find candidate by email. Create a key with candidates:read, candidates:write, jobs:read and webhooks (add team:read for the employee, training and review triggers) and paste it, with your Bússola address, when Zapier asks.
What the API never returns
No Social Security numbers, bank or tax data, pay, onboarding forms or documents, personal contacts or emergency contacts of team members, termination reasons, screening answers and scores, interview notes, review ratings or comments, rejection reasons, consent IP addresses or access tokens. Anonymized candidates are not returned. Every key and webhook change and every write through the API is recorded in the company audit log.