Skip to main content
Back to Portfolio
Project

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, per-source scheme:

Each source declares its own signature scheme. The native one is X-Switchyard-Signature: t=<unix>,v1=<hex> where v1 = hmac_sha256(secret, "{t}.{body}"), with 5-minute replay tolerance; a source can instead declare the fleet-wide standard-webhooks scheme published by the Interchange contract. While a source's dual_accept window is open, SchemeVerifier accepts either and records which one actually verified — the risk during a cutover is not that a bad signature gets through, it is that a good lead is silently rejected because sender and receiver disagree for ten minutes, and a dropped enquiry is indistinguishable from no enquiry. Those recorded verifications are what later prove the old scheme has gone quiet, from the database rather than from logs that rotate inside the same 30-day window the decision needs. 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.

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 232 tests covering parsers (with fixture payloads for Upwork + Fiverr email shapes) + enrichment + jobs + the API surface + signature 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, per-source signature schemes with a dual-accept window because a fleet-wide contract has to be adoptable without a flag-day cutover), 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

  • 232 PHPUnit tests (510 assertions) across unit + feature: parsers, scorer, tagger, OAuth state TTL, API client retry + 429 backoff + 401 disconnect, WebhookSigner sign/verify/replay/tamper, SchemeVerifier dual-accept + usage recording, Ingest pipeline + conditional dispatch, Route with Http::fake + idempotency + custom_fields assertion, Mirror status flow + envelope fallback, trace-context propagation, Lead API CRUD + lookup by trace id + scope rejection

  • OpenAPI 3.1 hand-authored — 29 operations with operationIds, 19 schemas, RFC 9457 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
Let's talk

Interested in working together?

Let's discuss how I can help with your project.

Send a message