Integrations

PitchPrfct

First-party SMS/CRM integration connected via API key — powers the dialer SMS tab and lands as the first entry in Project88's typed provider registry.

PitchPrfct is the first integration to land on Project88's typed provider registry — a folder-per-provider layout under src/integrations/ that drives a new Project 88 tab in the Connections settings modal, alongside the existing Integrations (Composio-managed) and Tools tabs.

The PitchPrfct entry covers all 46 endpoints across 8 resource groups through a single allowlisted path-passthrough edge function (pitchprfct-api), plus a HMAC-verified inbound webhook (pitchprfct-webhook). It powers both the native SMS app and the dialer's SMS tab.

Connect

  1. ⌘KSettings → Connections → Project 88 tab.
  2. Click Connect on the PitchPrfct card. The generic ApiKeyConnectModal opens — it matches the project's condensed ConfirmDialog styling and runs a per-provider validate() hook before persisting.
  3. Paste your PitchPrfct API key and submit. The key transits the pitchprfct-api edge function exactly once (with a validateKey envelope) to confirm it's live, then lands in Supabase Vault via the discriminator-shaped insert_integration_api_key RPC. The key never reaches the browser again after connect.

You'll see PitchPrfct in the Project 88 tab with a "Connected" status.

Where the credential lives

StoredNotes
Supabase Vault, via insert_integration_api_key (migration 126)Payload is the self-describing discriminator { type: 'api_key', key, provider } — distinct from the OAuth-shaped payload used by Gmail / Outlook / Google Calendar
RPC permissions (migration 127)REVOKE EXECUTE FROM anon; GRANT EXECUTE TO authenticated. Clears Supabase advisor lint 0028
Edge function in-memory cacheMap<userId, key> with a 5-min TTL, scoped per warm instance

The proxy never echoes the key back to the browser, and only allowlisted PitchPrfct paths can be reached through it.

Edge function: allowlisted path-passthrough

supabase/functions/pitchprfct-api is a single proxy with verify_jwt: true. One regex covers all 46 endpoints across the 8 resource groups, and a 26-scenario Deno suite asserts the allowlist plus a leak canary (the key must never appear in the response body or in proxied error envelopes).

The browser-side typed client (src/integrations/pitchprfct/client.js) is a hand-rolled JSDoc-typed JS wrapper (no TS in this codebase) with a generic pollJob() helper for the bulk endpoints.

Drift guard

A nightly contract test under .github/workflows/contract-tests.yml runs three live checks against PitchPrfct using the PITCHPRFCT_TEST_API_KEY GitHub secret. If PitchPrfct ships a breaking change, the workflow fails before the next user opens the dial widget.

Known upstream quirks

  • conversations.list({contactUuid}) returns HTTP 500 for valid UUIDs. The dialer SMS tab works around it by skipping the conversations endpoint and calling messages.list({contactUuid}) directly — a comment at the call site flags the workaround so it can be unwound once upstream fixes the bug.
  • /conversations intermittently hangs then recovers with a transient 5xx. pitchprfct-api retries once on 5xx / timeout (never on 401 / 429) and logs the upstream 5xx path so the pattern is visible in edge logs.

Sending: fromNumber resolution

PitchPrfct's POST /api/v1/messages requires a fromNumber header (per its OpenAPI spec). Resolution runs in this order:

  1. localStorage['dial.sms.pitchprfct.fromNumber'] override — an explicit power-user pin for teams that need to force a specific number.
  2. The number the conversation already lives on — a shared deriveConversationNumber reads the latest outbound message's fromNumber (falling back to an inbound message's toNumber) from the already-fetched history. Zero extra round trips, and replies stay on the same thread on the lead's phone instead of hopping numbers between sends.
  3. Phone-numbers APIGET /api/v1/phone-numbers as a fallback for brand-new leads with no history to derive from. pickSendingNumber skips enabled: false rows (released numbers stay listed with disabledReason: "user_deleted") and prefers default-flagged, then warmed-up numbers. Results are cached account-wide via React Query with a 10-min stale window; an account with no numbers surfaces an actionable error and evicts the empty result from cache so adding a number and retrying re-fetches.

The typed client exposes a phoneNumbers resource (list / get). The edge-function /api/v1/ allowlist already permits it — no backend change or redeploy needed.

Aug 2026 API-key regression

PitchPrfct silently revoked GET /api/v1/phone-numbers for existing API keys on Aug 9, 2026 (403 Forbidden; all other endpoints unaffected — a nightly contract test caught the same 403 independently). Freshly generated keys work.

Because contacts / messages endpoints still respond, threads with any history send fine even before the API key is rotated — the conversation-number path in step 2 above never touches the phone-numbers API. Only first-texts to brand-new leads require the fresh key.

Error surfacing

The client extracts the real upstream error text (PitchPrfct is NestJS — message may be a string or an array) instead of a bare HTTP 400, and both useDialSmsForLead and the SMS app carry the error message onto the failed bubble. Its retry-affordance tooltip explains why — opted-out contact, insufficient credits, no sending number — so a failed send never lands as an unexplained "Failed" pill.

Client API corrections

Three off-spec calls in the typed client were fixed while wiring live messages:

  • conversations.update now exists (PATCH {isRead}) — used by the inbox rail's Unread tab and the "click marks read" flow.
  • bulkUpdate moved to POST per the upstream OpenAPI spec.
  • Star is contact-level (PATCH /contacts/{uuid} {starred}) — it's a per-contact flag, not a per-conversation one. Every star toggle in the inbox rail resolves to the row's contact.

Adding another Project 88 integration

The registry layout is intentionally copy-paste-able. To wire a new first-party provider:

  1. Duplicate src/integrations/pitchprfct/ to src/integrations/<new-provider>/.
  2. Swap the provider definition in index.js (display name, icon, validate() hook, base URL).
  3. Update the typed client wrapper and its tests.
  4. Add the new provider to src/integrations/registry.js.

The same vault path, RPC, modal, and Project 88 tab pick it up automatically.

Webhooks

Inbound PitchPrfct deliveries push into Project88 in ~1 s via a Gmail-push-style pipeline:

PitchPrfct webhook → pitchprfct-webhook edge fn → sms_events (migration 158) → Supabase Realtime → cache invalidation in useSmsRealtime.

  • HMAC-SHA256 verified on {timestamp}.{body} with a replay window, plus a per-integration URL token. Events are idempotent on delivery id; 30-day retention.
  • Auto-registration. The first time an operator opens the SMS surface, a JWT-authed register action resolves the user's vault API key and idempotently creates or adopts the PitchPrfct webhook (message.* events). No manual dashboard step. Verified in prod: real signed deliveries land in sms_events and drive the UI immediately.
  • Polling demoted to fallback — thread 20 s, inbox 60 s. The dial widget's SMS tab and the SMS app mount the same realtime hook.

pitchprfct-webhook runs with verify_jwt: false — the token + HMAC combination is its authentication. sms_events and sms_webhook_state were introduced by the sms_realtime migration and are on the supabase_realtime publication.

Where to next

  • SMS app — the native surface built on this integration
  • Dial → SMS tab — the per-lead single-thread surface built on the same primitives
  • Vault and secrets — how Project88 stores credentials in general
  • Webhooks — the user-facing webhook trigger node for automations

On this page