Read leads, push leads, hand them off downstream, and receive lifecycle events. The same surface Krew uses internally - versioned at /api/v1/* with signed outbound webhooks for state changes.
All /api/v1/* requests require a Bearer token. Tokens are minted by the operator from the cockpit and shown to you exactly once at creation - store the plaintext somewhere safe (env var, secret manager).
Authorization: Bearer ai93_live_<your-key>Each token has scopes (leads:read, leads:write, leads:handoff, sources:read) and may be locked to a specific business - in which case all reads filter to that business automatically and writes must omit / match the business ID.
Failed auth returns 401 with a JSON { error, message }. Insufficient scope returns 403.
/api/v1/leadsscope: leads:readList leads. Filter by status, target_type, since, source, tag (one or comma-separated for AND), q (free-form match across contact name/email/phone/handle and notes), limit (max 500, default 100).
curl https://ai93.ca/api/v1/leads?status=new&limit=10 \
-H "Authorization: Bearer ai93_live_..."{
"leads": [
{
"id": "ld_a1b2c3...",
"businessId": "biz_...",
"targetType": "customer_lead",
"source": "reddit",
"status": "new",
"displayName": "u/some_redditor",
"context": { "subreddit": "HomeImprovement", "title": "...", "url": "..." },
"createdAt": "2026-05-04T13:22:01.000Z",
"handedOffAt": null,
"handoffDestination": null
}
],
"count": 10
}/api/v1/leads/{id}scope: leads:readFetch a single lead. Returns 404 (not 403) for leads outside your business scope.
/api/v1/leads.csvscope: leads:readSame filters as /api/v1/leads, returns text/csv. Suitable for ingesting into spreadsheets, BI, or another CRM. Defaults to 5000 rows, max 25000 per request.
curl https://ai93.ca/api/v1/leads.csv?status=converted&since=2026-01-01 \
-H "Authorization: Bearer ai93_live_..." \
-o leads.csv/api/v1/leadsscope: leads:writeCreate a lead. Business-scoped keys must omit businessId or match their scope.
curl -X POST https://ai93.ca/api/v1/leads \
-H "Authorization: Bearer ai93_live_..." \
-H "Content-Type: application/json" \
-d '{
"source": "manual",
"targetType": "customer_lead",
"displayName": "Jane Doe",
"email": "jane@example.com",
"phone": "+15555551234",
"context": { "url": "https://janes-business.com" }
}'{ "lead": { "id": "ld_...", "status": "new", ... } }/api/v1/leads/{id}/handoffscope: leads:handoffMark a lead as handed off to a downstream system (e.g. Krew). Sets handedOffAt + handoffDestination and fires a lead.handed_off event.
curl -X POST https://ai93.ca/api/v1/leads/ld_abc/handoff \
-H "Authorization: Bearer ai93_live_..." \
-H "Content-Type: application/json" \
-d '{ "destination": "krew", "metadata": { "krewLeadId": "kl_xyz" } }'/api/v1/sourcesscope: sources:readList supported lead sources (reddit, realtor_ca, google_maps, public_tool, facebook_lead_ad, manual) with each source's enrichment field schema and target type mapping.
/api/v1/scrapers/runsscope: sources:readRecent scraper run log. Filter by source, status, since, limit. Lets consumers monitor when AI93 last scraped each source and whether it's healthy.
When a lead changes state, AI93 POSTs the event to every active subscription. Subscriptions are minted by the operator from the cockpit and locked to either all businesses or a single business.
| Type | Fires when |
|---|---|
lead.created | A new lead is inserted (any source - scraper, public tool, FB ad, manual). |
lead.status_changed | Status moves (e.g. new → contacted → replied). |
lead.replied | An inbound reply is recorded against the lead. |
lead.converted | Status moves to converted (operator-triggered or via API). |
lead.handed_off | POST /handoff is called - the lead is now Krew's responsibility. |
{
"id": "evt_<16hex>",
"type": "lead.converted",
"createdAt": "2026-05-04T14:00:00.000Z",
"consumerWorkspace": "krew",
"data": {
/* Lead object - same shape as GET /api/v1/leads */
}
}Every delivery includes X-AI93-Signature: sha256=<hex> and X-AI93-Timestamp. The signature is HMAC-SHA256 of {timestamp}.{rawBody} using your subscription's signing secret (the whsec_… string shown once at mint time).
async function verify(req: Request, secret: string): Promise<boolean> {
const sig = req.headers.get('x-ai93-signature') ?? '';
const ts = req.headers.get('x-ai93-timestamp') ?? '';
const body = await req.text();
// Reject anything older than 5 minutes - replay protection.
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const key = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const sigBuf = await crypto.subtle.sign(
'HMAC', key, new TextEncoder().encode(`${ts}.${body}`)
);
const expected = 'sha256=' + Array.from(new Uint8Array(sigBuf))
.map((b) => b.toString(16).padStart(2, '0')).join('');
return sig === expected;
}X-AI93-Event - event type (e.g. lead.converted)X-AI93-Event-Id - idempotency key; safe to dedupe on thisX-AI93-Attempt - 1 for first fire, 2+ for retriesUser-Agent - AI93Platform/1.0 (or /1.0 (retry))Your endpoint should return any 2xx for success. Any non-2xx (or network error / timeout) is logged as failed and queued for retry.
Backoff schedule (cumulative from the initial fire): 1 min, 5 min, 30 min, 2 hr. After 5 total attempts the delivery dead-letters and the operator can force a manual retry from the cockpit.
Each fire is a separate row in our delivery log. Use X-AI93-Event-Id for idempotency - a successful retry will redeliver an event you already accepted, so dedupe on event id, not delivery id.
Request timeout: 10 seconds. Endpoints that need longer should ack fast and process async.
All endpoints return a JSON body on error:
{ "error": "missing_scope", "message": "API key lacks required scope: leads:write" }400 - bad input (missing required field, malformed body)401 - missing / invalid bearer token403 - token lacks required scope404 - resource not found (or out of business scope)500 - server error (will include a message)