Webhooks

Silky 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 $SILKY_HOST/api/v1/webhooks \
  -H "Authorization: Bearer $SILKY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.example.com/silky-webhook",
    "events": ["application.created", "application.assessed", "offer.accepted"]
  }'

Response:

{
  "success": true,
  "data": {
    "id": "whk_01HXXX...",
    "url": "https://your-app.example.com/silky-webhook",
    "events": ["application.created", "application.assessed", "offer.accepted"],
    "secret": "whsec_abc123...",
    "status": "active"
  }
}

The secret is returned once. Store it. It is the HMAC signing key for verifying deliveries.

Verify the signature

Every delivery includes two headers:

X-Silky-Timestamp: 1730000000
X-Silky-Signature: t=1730000000,v1=<hex-hmac-sha256>

Signature formula: HMAC-SHA256(secret, timestamp + "." + raw_body).

Node.js verification

import crypto from 'node:crypto';

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

Python verification

import hmac, hashlib, time

def verify(raw_body: bytes, header: str, secret: str) -> None:
    parts = dict(pair.split('=') for pair in header.split(','))
    t, v1 = parts['t'], parts['v1']
    expected = hmac.new(
        secret.encode(), f"{t}.{raw_body.decode()}".encode(), hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(expected, v1):
        raise ValueError('Bad signature')
    if abs(time.time() - int(t)) > 300:
        raise ValueError('Stale webhook')

Event catalogue

EventFires whenPayload highlights
account.createdSignup completesaccount_id, owner_email
account.enrichedAsync enrichment finishesbrand_colors, logo_url, timezone
job.createdJob spec drafted + readyjob_id, spec, assessment_criteria
job.publishedJob ad goes livejob_id, share_links
application.createdCandidate submitsapplication_id, candidate_id, job_id
application.parsedResume parse finishesresume_data
application.assessedAI assessment completesscore_total, per_criterion_scores
application.stage_changedAny transitionfrom_stage, to_stage, reason
interview.scheduledNew interviewinterview_id, starts_at, meet_url
interview.completedScorecard submittedscorecard, recommendation
reference.submittedReferee respondsreference_id, red_flags
offer.sentOffer emailed to candidateoffer_id, amount, currency
offer.acceptedCandidate signsoffer_id, signed_at
offer.declinedCandidate declinesoffer_id, reason
hire.createdApplication moves to hiredhire_id, start_date

Full payload schemas in the OpenAPI spec at /api/v1/openapi.json under components.schemas.WebhookEvent*.

Delivery guarantees

  • At-least-once delivery. Deduplicate on event.id.
  • Exponential backoff on failure: 30s, 2m, 10m, 1h, 6h, 24h. Six attempts total. After that we give up and mark the subscription as unhealthy.
  • A 2xx response within 10 seconds counts as success. Anything else is a failure.
  • Subscriptions that fail 10 deliveries in a row are auto-disabled. You will get an account.webhook_disabled event to your remaining active subs.

Rotating a secret

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

Returns a new whsec_.... The old secret keeps working for 24 hours to give you a window to update your verifier.

Deleting a subscription

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

Hand this to Claude

Subscribe a webhook at https://my-worker.example.com/silky to the events
application.assessed and offer.accepted. Store the returned secret in
our secrets manager under SILKY_WEBHOOK_SECRET. Show me the subscription
ID when done.