Last updated 22 July 2026

Webhooks

Lovelio fires webhooks on every meaningful state change. Prefer webhooks to polling whenever possible - they are cheaper for both sides and latency is sub-second.

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...",
    "url": "https://your-app.example.com/lovelio-webhook",
    "events": ["application.received", "application.scored", "placement.created"],
    "status": "active"
  },
  "secret": "whsec_abc123..."
}

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

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.

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.enrichedAsync candidate enrichment finishes
application.receivedA candidate is added to a job
application.scoredAI assessment completes
application.stage_changedAn application moves pipeline stage
application.status_changedAn application is rejected or withdrawn
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 shortlist 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
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.
  • Exponential backoff on failure across six attempts, then the delivery is dead-lettered (visible via GET /api/v1/webhooks/{id}/deliveries).
  • 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 a new whsec_....

Deleting a subscription

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

Hand this to Claude

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.