Webhook flow
Every scraper worker posts its results back to the platform when a run completes. The webhook endpoint authenticates the call, dispatches by source, and upserts the results into the right database table. The skill that fired the run reads from those tables on its next turn; the workflow continues without polling.
Overview
- Endpoint
POST /api/apify-webhook- Handler
src/app/api/apify-webhook/route.ts- Auth header
x-webhook-secret- Shared secret
APIFY_WEBHOOK_SECRETenv- Source param
?source=gmaps | jobs | linkedin | twitter | email | profile-changes- Upstream
- Every scraper worker after a successful run
The webhook endpoint is the integration point between the scraper workers and the rest of the platform. The workers do not write to the application database directly because they live in different runtimes (Apify for actors, Cloudflare for the marketing worker) and the application database is the platform's source of truth. The webhook centralises every write through one auth-gated route.
The webhook endpoint
One route. Authenticate, parse, dispatch.
Authentication
One header. One env var. No exceptions.
x-webhook-secret value as the platform's APIFY_WEBHOOK_SECRET env. A mismatch returns 401 immediately. Rotate the secret by updating both sides in the same deploy window.Source dispatch
One handler per scraper.
| source | Handler | Target table | Dedup key |
|---|---|---|---|
| gmaps | handleGmaps | leads | (user_id, enrichment_data ->> place_id) OR (name + address) |
| jobs | handleJobs | job_signals | (user_id, jd_url) |
| handleLinkedIn | leads | (user_id, enrichment_data ->> profile_id) | |
| handleTwitter | leads | (user_id, enrichment_data ->> twitter_user_id) | |
| handleEmailEnrichment | leads (update) | lead_id from input | |
| profile-changes | handleProfileChanges | audit row + reprocess | - |
gmaps handler
One row per place. Upsert into leads.
jobs handler
One row per posting. Upsert into job_signals.
email handler
Joins enrichment results back to existing leads by lead_id.
The email handler is the only one that updates rather than inserts. It reads lead_id on every row and patches the corresponding lead with the best email, the verification status, the confidence, and the alternates. The activity log gets a single line per run summarising how many leads moved from no-email to email.
linkedin handler
One row per profile. Upsert into leads with profile_id as the dedup key.
The LinkedIn handler is structurally identical to gmaps but with a different dedup column. Profile id is opaque and stable across LinkedIn rewrites, which makes it a more reliable key than name plus company.
twitter handler
One row per author. Upsert into leads with twitter_user_id as the dedup key.
Twitter authors are upserted with an empty email and a Twitter handle in enrichment_data. Downstream skills can fire the email finder to attach addresses where possible; the leads stay even when no email materialises because Twitter DMs are a viable outreach channel for the angel persona.
Failure modes
What goes wrong on the receive side and how the endpoint responds.
| Failure | Endpoint response |
|---|---|
| Missing or wrong x-webhook-secret | 401 unauthorized |
| Unknown source query param | 400 unknown source |
| Payload missing user_id or conversation_id | 400 missing user_id |
| Upstream worker timed out and resent the same batch | Idempotent upsert; second call writes nothing new |
| Database transient error | 500 with the error string; the worker retries per Apify's webhook retry policy |
| Activity log write fails after upsert succeeded | Logged but does not fail the webhook response (we keep the data, lose only the log line) |