Skip to main content
Back to Portfolio

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.

Laravel 13PHP 8.3MySQL 8Redis 7Laravel HorizonFilament 5Next.js 16React 19TypeScriptTailwind 4OpenAPI 3.1ScalarClickUp v2 OAuthHMAC SHA-256Apachephp-fpmsupervisord
Switchyard preview

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:

  • IngestLeadJobLeadParserResolver picks the right parser (slug priority over kind fallback), parses raw → contact + job, tags via ServiceTagger, suggests via ExampleSuggester, scores via LeadScorer, persists the Lead row, emits received and enriched events, conditionally dispatches RouteLeadToClickUpJob when config + active connection are present.
  • RouteLeadToClickUpJob — idempotent on clickup_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_text supported), config_incomplete emits an error event without throwing.
  • MirrorClickUpStatusJob — reads new status from history_items or task envelope, maps via per-workspace status_mapping JSON, sets won_at/lost_at/dismissed_at on terminal status transitions, emits status_changed (mapped) or mirrored (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

Switchyard screenshot 2
Switchyard screenshot 3
Switchyard screenshot 4

Interested in working together?

Let's discuss how I can help with your project

Get in Touch