Switchyard
Multi-tenant ClickUp lead-intake API. Normalizes inquiries from contact forms, Upwork, Fiverr, and email into enriched ClickUp tasks with bidirectional status mirroring. Production-shaped Laravel + Next.js + Filament + OpenAPI portfolio demo.

Overview
Sales-ops teams at dev shops actually live in a PM tool (ClickUp, Asana, Linear), not a CRM — that's where the work is. Off-the-shelf form-to-PM glue is brittle: Zapier per-step billing, no enrichment, no idempotency, no audit trail, no way to mirror PM-side status back without a second Zap.
Switchyard answers the gap. Per-source parsers (ScopeForged contact form, Upwork notification email, Fiverr DMs, generic webhook via field_mapping, manual paste-in) hand off to a queue pipeline: parse → keyword-tag against the workspace taxonomy → suggest matching portfolio examples → score 0-100 over budget + timeline + contact completeness → write a ClickUp task with custom_fields-populated payload. ClickUp webhook callbacks mirror status changes back into the local lead row, so the inbox + audit log stay in lockstep.
The wedge — wired through every layer:
Lead intake is the visible surface; the load-bearing differentiator is that the same code paths run multi-tenant from day one. Every workspace has its own ClickUp OAuth connection, its own per-source HMAC secrets, its own swy_ bearer keys, its own status mapping and taxonomy. The WorkspaceScope global scope on every model is the policy floor; cross-tenant reads are structural impossibilities, not policy checks that can be forgotten.
ClickUp v2 OAuth — the quirks are load-bearing:
ClickUp's OAuth v2 does not support PKCE and does not return a refresh token. Tokens are long-lived until the user revokes them. The state-in-session check is the CSRF defense; ensureFresh() is a no-op (kept on the surface for forward-compat). Per-request Authorization is the raw token, not Bearer <token>. A 401 means the user revoked — the API client nulls the token, marks last_sync_error.kind = unauthorized, and flips disconnected_at so the operator must reconnect rather than the queue retrying silently.
Retry + rate-limit handling:
ClickUpApiClient runs 5 attempts with exponential backoff on 429 + 5xx (1→2→4→8→16s, capped at 30s). Laravel's RateLimiter facade enforces 100 req/min per connection ID — when exhausted, the request sleeps before the next attempt. A closure-injected sleeper makes the whole retry loop deterministic in tests without actually sleeping.
Signed inbound webhooks (Stripe/Inkwell-style):
Vendor → Switchyard webhooks use X-Switchyard-Signature: t=<unix>,v1=<hex> where v1 = hmac_sha256(secret, "{t}.{body}"). 5-minute replay tolerance. Per-Idempotency-Key 24-hour dedup against an idempotency_records table — a repeated key returns the original 202 response and does not enqueue a second ingest. Optional but recommended for at-least-once delivery semantics.
Three queue jobs, full path:
IngestLeadJob—LeadParserResolverpicks the right parser (slug priority over kind fallback), parses raw → contact + job, tags viaServiceTagger, suggests viaExampleSuggester, scores viaLeadScorer, persists theLeadrow, emitsreceivedandenrichedevents, conditionally dispatchesRouteLeadToClickUpJobwhen config + active connection are present.RouteLeadToClickUpJob— idempotent onclickup_task_id(no-ops if already routed), builds the task name + Markdown description + custom_fields per mapping (service_tag,lead_score,source_slug,contact_email,contact_name,budget_text,timeline_textsupported),config_incompleteemits anerrorevent without throwing.MirrorClickUpStatusJob— reads new status fromhistory_itemsortaskenvelope, maps via per-workspacestatus_mappingJSON, setswon_at/lost_at/dismissed_aton terminal status transitions, emitsstatus_changed(mapped) ormirrored(unmapped) for full audit.
Filament v5 admin — the operator's view:
Five resources cover the full surface. LeadResource is the inbox — sortable table with score color-grading, source filter, status filter, manual Route and Dismiss actions, full ViewLead detail with KeyValueEntry for parsed_contact/parsed_job + the raw payload pretty-printed. LeadSourceResource includes a reveal-once HMAC secret modal + rotate-secret action. ClickUpConnectionResource exposes "Connect ClickUp" header action (routes to the OAuth controller), per-row Test connection + Disconnect actions. ClickUpConfigResource uses KeyValue inputs for taxonomy + portfolio examples + status mapping + custom-field mapping. ApiKeyResource generates swy_ tokens that flash once via persistent Notification.
Next.js 16 docs site — 7 routes:
/ (hero + 6-card feature grid + In/Out architecture block), /docs (Scalar try-it console rendering the hand-curated openapi/spec.yaml), /docs/quickstart (3-step path with cURL example + OAuth-app callout), /docs/sources (per-source wiring guides for ScopeForged form, Upwork email, Fiverr email, generic webhook with field_mapping example, manual paste), /docs/webhooks (Node createHmac signing recipe + idempotency contract + ClickUp signature scheme), /demo (12 seeded fictional leads with client-side simulated routing + filter pills + reseed button), /roadmap (Phases 2-4 sketches).
Phases 2-4 — schema seams left intact, not implemented:
Phase 2 client status board (client_projects + signed /status/[token] route), Phase 3 proposal → project scaffolder (templates + scaffold_runs triggered on lead.status = won), and Phase 4 time → invoice (Stripe + ClickUp time-tracking mirror) all reuse the existing ClickUpApiClient, WorkspaceScope, and lead_events event bus — none require Phase 1 schema rewrites.
What it proves:
Same person hand-authored the data model + the OpenAPI spec + the Filament resources + the Next.js docs + the Apache vhost configs + the supervisord conf + the 10-step infra/RUNBOOK.md, wrote ~90 tests covering parsers (with fixture payloads for Upwork + Fiverr email shapes) + enrichment + jobs + the API surface + HMAC verification, picked the right tradeoffs at every layer (no PKCE because ClickUp doesn't support it, raw Authorization because ClickUp doesn't use Bearer, no refresh because tokens are long-lived, Inkwell-style HMAC because it's the same shape across Stripe/GitHub/HubSpot so operators can build mental muscle memory), and shipped it in a single focused session with the schema seams for the next three phases deliberately preserved. The case for hiring me to ship a multi-tenant integration that won't collapse on contact with a real second customer — and that you'll still want to extend two phases from now.
Results
5 parsers (ScopeForged form, Upwork email, Fiverr email, generic JSON, + slug-priority resolver) — fixture-tested with realistic payloads
ClickUp v2 OAuth shipped with the quirks documented inline — no PKCE, raw Authorization, no refresh, 401 = revoked
~90 PHPUnit assertions across unit + feature: parsers, scorer, tagger, OAuth state TTL, API client retry + 429 backoff + 401 disconnect, WebhookSigner sign/verify/replay/tamper, Ingest pipeline + conditional dispatch, Route with Http::fake + idempotency + custom_fields assertion, Mirror status flow + envelope fallback, Inbound HMAC, Lead API CRUD + scope rejection
OpenAPI 3.1 hand-authored — 9 operations with operationIds, 7 schemas, RFC 7807 problem responses, Spectral-clean
Multi-tenant from day one — WorkspaceScope + BelongsToWorkspace on every data model; per-tenant OAuth tokens, webhook secrets, API keys, status mapping
Phases 2-4 (status board, scaffolder, time → invoice) schema seams documented + roadmap page; no Phase 1 concessions
Gallery


