Tony Belleza

Bússola

API and webhooks

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.

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"
ScopeAllows
candidates:readList and read candidates and their applications
candidates:writeCreate candidates and change the stage (except rejection)
candidates:rejectMove to rejected, only when the admin also turns on Allow rejecting candidates through the API
jobs:readList and read jobs
team:readList and read team members
webhooksCreate, 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

{"error": {"code": "insufficient_scope", "message": "This key does not have the candidates:read scope."}}
StatusCodes
400invalid_limit, invalid_cursor, invalid_stage, invalid_date, invalid_body, invalid_consent_attestation, key_in_url, invalid_path
401unauthorized (missing, wrong or revoked key)
403insufficient_scope, plan_required, rejection_requires_human, human_review_required
404not_found, application_not_found
409duplicate (with candidate_id), ambiguous_application, no_application
422missing_name, missing_contact, invalid_email, invalid_phone, invalid_language, invalid_job_id, invalid_url, url_not_allowed, invalid_events
429rate_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.

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:

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.

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:

EventsScope also required
candidate.created, candidate.stage_changed, screening.completed, interview.booked, interview.canceled, offer.sent, offer.signedcandidates:read
employee.hired, employee.terminated, training.completed, review.acknowledgedteam: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"
  }
}
EventWhendata
candidate.createdA candidate is created (careers page, Indeed, by hand, API, inbound call)candidate (id, name, email, phone, location, source, created_at), applications
candidate.stage_changedAny stage change (manager, automation, scheduling, offer, API)candidate, application, from_stage, to_stage
screening.completedA voice or form screening is scoredcandidate, application, screening (channel voice or form, completed_at). Scores stay in Bússola for a person to review.
interview.bookedAn interview is booked or rescheduledcandidate, application, interview (id, start, end, rescheduled)
interview.canceledAn interview is canceledcandidate, application, interview (id, start, end)
offer.sentAn offer and onboarding are sentcandidate, offer (id, employment_type, job_title, start_date, application_id). No pay.
offer.signedThe candidate signs the offer letterSame as offer.sent
employee.hiredA person is added to Team (hired)employee (id, name, employment_type, status, start_date, job_title, location, work_email, candidate_id)
employee.terminatedA team member's status becomes terminatedemployee (id, name, end_date). No reason or type.
training.completedA training assignment is completedemployee (id, name), course (id, title), completed_at
review.acknowledgedA team member acknowledges a performance reviewemployee (id, name), review (id, cycle_id, milestone, acknowledged_at). No ratings or comments.
testSend test eventmessage, 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.