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.
| Event | Fires when |
|---|---|
account.enriched | Async account enrichment finishes |
candidate.enriched | Async candidate enrichment finishes |
application.received | A candidate is added to a job |
application.scored | AI assessment completes |
application.stage_changed | An application moves pipeline stage |
application.status_changed | An application is rejected or withdrawn |
job.created | A job is created and its draft pipeline finishes |
job_ad.published | A job ad goes live |
interview.scheduled | An interview is booked |
interview.cancelled | An interview is cancelled |
interview.outcome_recorded | An interview outcome or scorecard is recorded |
submission.created | A shortlist is sent to a client contact |
submission.responded | A client requests an interview, passes, or asks a question |
placement.created | A placement is logged (the win) |
placement.status_changed | A placement's guarantee status changes |
email.sent | A tracked email is sent |
email.scheduled | An email is scheduled to send later |
email.cancelled | A scheduled email is cancelled |
reference.received | A candidate submits referee details |
spec.sent | A spec (float) is sent: an anonymous candidate profile to a client with no job |
spec.viewed | A client contact opens a spec profile page for the first time |
spec.responded | A client responds to a spec (interested, a question, or not right now) |
spec.converted | An interested spec becomes a real job + application |
marketplace.intro.received | Another agency submits a candidate to your marketplace listing, or requests your shared candidate |
marketplace.deal.formed | A marketplace intro is accepted - the split deal forms and identity reveals (fires for both agencies) |
marketplace.deal.placed | A 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 toplacement.createdandplacement.status_changedinstead.
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.