Webhooks

Lovelio fires webhooks on meaningful state changes. Prefer webhooks to polling whenever possible because they are cheaper for both sides.

Subscribe

curl -X POST $LOVELIO_HOST/api/v1/webhooks \
  -H "Authorization: Bearer $LOVELIO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "url": "https://your-app.example.com/lovelio-webhook",
    "events": ["application.received", "application.scored", "placement.created"]
  }'

Response:

{
  "success": true,
  "data": {
    "id": "whk_01HXXX...",
    "account_id": "acc_01HXXX...",
    "url": "https://your-app.example.com/lovelio-webhook",
    "events": ["application.received", "application.scored", "placement.created"],
    "status": "active",
    "created_at": "2026-08-28T02:15:00.000Z",
    "signing_secret": "whsec_abc123..."
  }
}

data.signing_secret is returned once. Store it. It is the HMAC signing key for verifying deliveries. Use ["*"] to subscribe to every event.

Creating a subscription requires webhooks:write. Reading the event catalogue, subscriptions or delivery history requires webhooks:read. These are the permissions declared by the webhook routes, not a separate permission scheme.

Verify the signature

Every delivery includes three headers:

X-Lovelio-Event: application.scored
X-Lovelio-Timestamp: 1730000000
X-Lovelio-Signature: sha256=<hex-hmac-sha256>

Signature formula: HMAC-SHA256(secret, timestamp + "." + raw_body), hex-encoded, prefixed with sha256=.

Node.js verification

import crypto from 'node:crypto';

function verify(rawBody, signatureHeader, timestampHeader, secret) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(`${timestampHeader}.${rawBody}`)
    .digest('hex');
  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader))) {
    throw new Error('Bad signature');
  }
  // Reject replay attacks - timestamp older than 5 minutes.
  if (Math.abs(Date.now() / 1000 - Number(timestampHeader)) > 300) {
    throw new Error('Stale webhook');
  }
}

Python verification

import hmac, hashlib, time

def verify(raw_body: bytes, signature: str, timestamp: str, secret: str) -> None:
    expected = 'sha256=' + hmac.new(
        secret.encode(), f"{timestamp}.{raw_body.decode()}".encode(), hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(expected, signature):
        raise ValueError('Bad signature')
    if abs(time.time() - int(timestamp)) > 300:
        raise ValueError('Stale webhook')

Delivery payload

Every delivery is a JSON body of the same shape:

{
  "event": "placement.created",
  "event_id": "evt_abc123...",
  "account_id": "acc_...",
  "created_at": "2026-07-06T12:00:00.000Z",
  "data": { "...event-specific fields..." }
}

Deduplicate on event_id.

placement.updated

data is the placement exactly as GET /api/v1/placements/{id} returns it to your key (so a connection without the financial or contracting scope sees those fields null), plus changed_fields: the API field names that moved, such as ["contract.charge_rate", "contract.rate_history", "contracting.timesheet_approvers"]. Changes made together, like one PATCH that moves the rates, the credit split and the approvers, arrive as one event. A change to a contractor's payroll details (ABN, entity, bank) fires for each of their live contract placements in that country, with contracting.contractor in changed_fields.

placement.created, placement.status_changed and placement.updated all carry the placement's number (1042) and reference ("P-1042").

candidate.* and client_contact.*

Keep a dialler, CRM or mirror in step without polling. candidate.created, candidate.updated and candidate.deleted fire for people records, and client_contact.created, client_contact.updated and client_contact.deleted fire for client contacts. They fire for every route: the app, the API, imports, the browser extension and background jobs.

  • data on created and updated is the record exactly as GET /api/v1/candidates/{id} or GET /api/v1/clients/{id}/contacts returns it to your key. A key without candidates:read (or clients:read for contacts) gets the id only. cv_url is null here; fetch the candidate for a fresh signed link.
  • data on updated also carries changed_fields, the API field names that moved, such as ["email", "phone"]. Tag changes show as tags, and preferred locations as preferred_locations. Changes that no API field shows, like background enrichment bookkeeping, send nothing.
  • data on deleted is { id, deleted: true } (a contact also carries client_id and person_id).
  • A contact's name, email and phone are the person's. Changing them on the person sends client_contact.updated for each of that person's contact rows. A person who exists only as a client contact is not a candidate and sends no candidate.* events until they enter a candidate process; that first event is candidate.created.
  • Events are sent within about a minute of the change, not instantly. Several changes to one record in that window arrive as one event with every field in changed_fields.
  • Delivery is at least once. Deduplicate on event_id, and read data as the latest state.

Event catalogue

Every event below is really emitted by Lovelio. Subscribe to any subset, or * for all. Fetch this catalogue programmatically from GET /api/v1/webhooks/events. The SDKs ship a ready-made signature verifier.

EventFires when
account.enrichedAsync account enrichment finishes
candidate.createdA candidate is added, by any route
candidate.updatedA candidate field changes: carries the candidate plus changed_fields
candidate.deletedA candidate is deleted
candidate.enrichedAsync candidate enrichment finishes
client_contact.createdA contact is added to a client
client_contact.updatedA client contact changes, including the person's name, email or phone
client_contact.deletedA client contact is removed
application.receivedA candidate is added to a job
application.scoredAI assessment completes
application.stage_changedAn application moves to another stage, forwards or backwards
application.status_changedThe older name for application.stage_changed: same moment, same payload, on every stage move. Subscribe to one of them, not both
job.createdA job is created and its draft pipeline finishes
job_ad.publishedA job ad goes live
interview.scheduledAn interview is booked
interview.cancelledAn interview is cancelled
interview.outcome_recordedAn interview outcome or scorecard is recorded
submission.createdA submission is sent to a client contact
submission.respondedA client requests an interview, passes, or asks a question
placement.createdA placement is logged (the win)
placement.status_changedA placement's guarantee status changes
placement.updatedAnything on a placement changes: its fields, rates, credit split, timesheet approvers, or the contractor's payroll details
email.sentA tracked email is sent
email.scheduledAn email is scheduled to send later
email.cancelledA scheduled email is cancelled
reference.receivedA candidate submits referee details
spec.sentA spec (float) is sent: an anonymous candidate profile to a client with no job
spec.viewedA client contact opens a spec profile page for the first time
spec.respondedA client responds to a spec (interested, a question, or not right now)
spec.convertedAn interested spec becomes a real job + application
marketplace.intro.receivedAnother agency submits a candidate to your marketplace listing, or requests your shared candidate
marketplace.deal.formedA marketplace intro is accepted - the split deal forms and identity reveals (fires for both agencies)
marketplace.deal.placedA placement lands on a marketplace deal - the shared money snapshot is stamped (fires for both agencies)

Offer events (offer.*) were retired when placements replaced the offer machinery. In agency recruitment the client makes and signs the offer with the candidate, so the win is recorded as a placement. Subscribe to placement.created and placement.status_changed instead.

Delivery guarantees

  • At-least-once delivery. Deduplicate on event_id.
  • Failed deliveries retry with backoff and eventually become failed (visible via GET /api/v1/webhooks/{id}/deliveries). Read the current OpenAPI operation for the delivery policy rather than hard-coding an attempt count in your integration.
  • A 2xx response within 10 seconds counts as success. Anything else is a failure.
  • Replay a specific delivery with POST /api/v1/webhooks/{id}/deliveries/{deliveryId}/replay.

Rotating a secret

curl -X POST $LOVELIO_HOST/api/v1/webhooks/whk_01HXXX/rotate-secret \
  -H "Authorization: Bearer $LOVELIO_API_KEY"

Returns the subscription with a new data.signing_secret (whsec_...), shown once. The old secret stops verifying immediately.

Deleting a subscription

curl -X DELETE $LOVELIO_HOST/api/v1/webhooks/whk_01HXXX \
  -H "Authorization: Bearer $LOVELIO_API_KEY"

Hand this to your agent

Subscribe a webhook at https://my-worker.example.com/lovelio to the events
application.scored and placement.created. Store the returned secret in
our secrets manager under LOVELIO_WEBHOOK_SECRET. Show me the subscription
ID when done.