Developer docs

Build with UpHex API

Use this service as the controlled API edge for UpHex. Domain work stays in the existing backend services; this repo owns access, limits, logs, docs, webhooks, and stable public contracts.

OpenAPI YAML
Legacy Migration Guide (90-Day Dual-Run Window)

Migrating from Legacy api_clients to Developer Portal API Credentials

90-Day Dual-Run Migration Window

UpHex Public API credentials are unifying onto the Developer Portal edge at api.uphex.com. Legacy backend api_clients keys and POST /public_api/v1/tokens authentication are deprecated. A 90-day dual-run window operates from July 28, 2026 to October 26, 2026, during which legacy and portal credentials operate in parallel. All legacy holders must create Developer Apps and issue portal API keys before the October 26, 2026 cutover deadline.

Step 1: Sign in & Create a Developer App

Sign in to the Developer Portal at api.uphex.com. Go to Apps and click Create App. Developer Apps group API keys, OAuth clients, rate limits, and audit logs for your integration.

Step 2: Issue Scoped Portal API Keys

Inside your app, click Issue API Key. Select the minimum required scopes (such as clients:read, templates:read, metrics:read, leads:read, or leads:write). Copy your API key and API secret immediately—secrets are shown only once.

Step 3: Update Integration Request Headers

Replace legacy POST /public_api/v1/tokens exchange requests with direct API key headers. Send X-API-Key, X-API-Secret, and X-Agency-Id directly on requests to api.uphex.com, or use portal-issued OAuth access tokens.

Step 4: Verify Traffic & Confirm Cutover

Use the Developer Portal Request Log at /portal/requests to confirm requests succeed via portal credentials. Verify zero remaining calls on legacy keys before the 90-day dual-run window elapses on October 26, 2026.

Scope & Gated Cutover Schedule

Note: Legacy api_clients authentication removal and /v1/tokens endpoint retirement are scheduled for a future gated cutover (Story 7) after October 26, 2026. Legacy auth removal is explicitly out of scope for this inventory and comms phase; legacy credentials remain fully supported throughout the 90-day dual-run window.

1. Create a developer app

Apps group API keys, OAuth/MCP clients, webhook endpoints, scopes, rate limits, and audit logs. Per-app limits are capped by the API edge, and portal-managed scope lists are bounded before storage.

2. Issue a scoped API key

Use API keys for server-to-server requests. Secrets are shown once, can be time-boxed with an expiration date, rotated without changing the key id, and are stored only as digests. Legacy token exchange attempts are rate limited by source IP and presented API key before credential validation.

3. Use OAuth for delegated tools

OAuth/MCP clients use authorization code flow with PKCE S256 through /oauth/authorize, /oauth/token, and /oauth/revoke. Consent is limited to developer users in the OAuth client's owning account, except for UpHex-approved public apps (see Publishing your app): any agency user (owner, admin, or developer role) can install a public app on one of their agencies, creating one installation per agency that they can revoke from Connected apps. Authorization client ids, scopes, state, and PKCE challenge values are bounded before lookup, redirects, or code issuance. Token and revocation endpoints accept `client_secret_basic` and `client_secret_post` for generated UpHex OAuth client credentials and bounded grant/token values, bound Basic authentication before base64 decoding, reject duplicate `Authorization` headers, are rate limited by source IP and presented client id, and return `Retry-After` when throttled. Revoking an unknown or cross-client token is a successful no-op after client authentication. Token and revocation attempts are request-logged with safe client identifiers only. Client secrets can be rotated from the portal when an integration needs a new secret.

4. Add webhooks

Add an HTTPS webhook endpoint to the app in the portal and copy its one-time `whsec_` signing secret. Endpoints receive the CRM lead, message, and appointment events they list for agencies the app is authorized for, including `lead.created` for leads created through `POST /v1/leads`.

Authentication

Server-to-server calls use API key headers (or the same key and secret over HTTP Basic). OAuth/MCP clients use the authorization code flow with PKCE S256 and refresh tokens; there is no client_credentials grant.

API key headers (Developer Portal credentials)

Primary authentication for all server-to-server integrations. Issue scoped API keys in the Developer Portal at api.uphex.com and send X-API-Key, X-API-Secret, and X-Agency-Id directly on requests to api.uphex.com. Authentication headers must be sent at most once and cannot be combined with bearer authentication.

curl -H "X-API-Key: uphex_key_..." \
  -H "X-API-Secret: uphex_secret_..." \
  -H "X-Agency-Id: 96" \
  https://api.uphex.com/v1/clients

API key over HTTP Basic

Equivalent to the API key headers: send `Authorization: Basic base64(api_key:api_secret)`. Do not combine it with `X-API-Key` or `X-API-Secret` headers.

curl -u "uphex_key_...:uphex_secret_..." \
  -H "X-Agency-Id: 96" \
  https://api.uphex.com/v1/health

Legacy bearer token (Deprecated migration exchange)

DEPRECATED (90-day dual-run window active: July 28, 2026 – October 26, 2026). Exchanges a Developer Portal API key and secret for a one-hour bearer token (`expires_in: 3600`) with the same scopes; backend api_clients keys are not accepted. New integrations should send API key headers directly or use OAuth access tokens.

curl -X POST https://api.uphex.com/v1/tokens \
  -H "Content-Type: application/json" \
  -d '{"api_key":"uphex_key_...","api_secret":"uphex_secret_..."}'

# => 201 {"token":"...","token_type":"Bearer","expires_in":3600}
curl https://api.uphex.com/v1/clients \
  -H "Authorization: Bearer <token>" \
  -H "X-Agency-Id: 96"

OAuth step 1: send the developer user to /oauth/authorize

Generate a random `code_verifier` (43-128 characters of A-Z, a-z, 0-9, `-._~`) and send its base64url SHA-256 (no padding) as `code_challenge`. The user must be signed in to the Developer Portal in the account that owns the OAuth client; for public apps, any agency user signs in and picks which of their agencies to connect (an `agency_id` field on the consent form). After approval the browser returns to your registered `redirect_uri` with `code` (single-use, valid 10 minutes) and `state`.

https://api.uphex.com/oauth/authorize?response_type=code
  &client_id=uphex_oauth_...
  &redirect_uri=https%3A%2F%2Fexample.com%2Foauth%2Fcallback
  &scope=leads%3Aread%20leads%3Awrite
  &state=random-state
  &code_challenge=<base64url(sha256(code_verifier))>
  &code_challenge_method=S256

OAuth step 2: exchange the code, then refresh

Authenticate the client with HTTP Basic (`client_secret_basic`) or `client_id`/`client_secret` fields (`client_secret_post`). Responses include `access_token` (1 hour), `refresh_token` (30 days), `expires_in`, `refresh_token_expires_in`, and `scope`. Each refresh returns a new pair and revokes the previous access and refresh token. Tokens are bound to one agency chosen at authorization. Partner-app tokens are also bound to that agency's installation: they never exceed the installation's granted scopes, and revoking the installation revokes them immediately (refresh and unexchanged codes fail with `invalid_grant`). Partner-app requests whose `client_id` is not owned by the installation's agency return `404 client_not_found`.

curl -X POST https://api.uphex.com/oauth/token \
  -u "uphex_oauth_...:uphex_oauth_secret_..." \
  -d grant_type=authorization_code \
  -d code=uphex_auth_code_... \
  -d redirect_uri=https://example.com/oauth/callback \
  -d code_verifier=<code_verifier>

curl -X POST https://api.uphex.com/oauth/token \
  -u "uphex_oauth_...:uphex_oauth_secret_..." \
  -d grant_type=refresh_token \
  -d refresh_token=uphex_refresh_...

curl -X POST https://api.uphex.com/oauth/revoke \
  -u "uphex_oauth_...:uphex_oauth_secret_..." \
  -d token=uphex_refresh_...

Webhooks

Add webhook endpoints to an app in the portal. UpHex signs each delivery so you can confirm it came from UpHex before trusting the payload.

lead.created delivery

POSTed as JSON with `UpHex-Event`, `UpHex-Delivery-Id`, `UpHex-Signature-Timestamp`, `UpHex-Signature: v1=<hex>`, and `User-Agent: UpHex-Webhook/1.0`. `lead.created` is emitted by UpHex's CRM once per created lead, so a `POST /v1/leads` retry or replay never sends a second event.

{
  "event": "lead.created",
  "data": {
    "event_id": "5f0c6a52-6b1e-4d0e-9a51-0c2f3f0d9b11",
    "type": "lead.created",
    "agency_id": 96,
    "client_id": 123,
    "occurred_at": "2026-10-21T15:04:05Z",
    "data": {
      "lead_id": 987,
      "lead": {
        "id": 987,
        "client_id": 123,
        "full_name": "Ada Lovelace",
        "email": "ada@example.com",
        "phone": "555-0100",
        "status": "New",
        "tags": []
      }
    }
  }
}

CRM event delivery (message.inbound)

Lead, message, and appointment events from the CRM share one shape: `data` is `{event_id, type, agency_id, client_id, occurred_at, data}`. Same headers and signature as above; `event_id` identifies the source change.

{
  "event": "message.inbound",
  "data": {
    "event_id": "6f1c1d2e-8a1b-4c3d-9e4f-5a6b7c8d9e0f",
    "type": "message.inbound",
    "agency_id": 96,
    "client_id": 123,
    "occurred_at": "2026-10-22T18:00:00.000000Z",
    "data": {"channel": "sms", "lead_id": 987, "body": "Is Tuesday still open?"}
  }
}

Verify the signature (Node.js)

The HMAC key is the lowercase hex SHA-256 of your `whsec_` secret (the 64-character string is the key). Sign `timestamp + "." + raw body` with HMAC-SHA256 and compare hex digests in constant time. Use the raw request bytes, not re-serialized JSON.

const crypto = require("crypto");

function verifyUphexWebhook(rawBody, headers, signingSecret) {
  const timestamp = headers["uphex-signature-timestamp"] || "";
  const header = headers["uphex-signature"] || "";
  if (!/^\d+$/.test(timestamp) || !header.startsWith("v1=")) return false;

  const key = crypto.createHash("sha256").update(signingSecret).digest("hex");
  const expected = crypto
    .createHmac("sha256", key)
    .update(timestamp + ".")
    .update(rawBody)
    .digest("hex");
  const received = header.slice(3);

  return received.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}

Verify the signature (Python)

Same algorithm. UpHex does not enforce a timestamp tolerance, so also reject timestamps older than your replay window (for example 5 minutes) and de-duplicate on `UpHex-Delivery-Id`.

import hashlib, hmac

def verify_uphex_webhook(raw_body: bytes, headers, signing_secret: str) -> bool:
    timestamp = headers.get("UpHex-Signature-Timestamp", "")
    signature = headers.get("UpHex-Signature", "")
    if not timestamp.isdigit() or not signature.startswith("v1="):
        return False
    key = hashlib.sha256(signing_secret.encode()).hexdigest().encode()
    expected = hmac.new(key, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature[3:])

Endpoints and scopes

GET /v1/health Validate credentials and inspect the current app context. any active API key
POST /v1/tokens Exchange a portal API key and secret for a one-hour bearer token with the same scopes (deprecated; 90-day dual-run window active for migration to portal api_credentials). api_key + api_secret
GET /v1/clients List CMS-owned agency clients, ordered by name. clients:read
GET /v1/templates List read-only CMS-owned template creative previews. templates:read
GET /v1/templates/{id} Read one CMS-owned template creative preview. templates:read
POST /v1/templates Create a CMS-owned API draft template. templates:write
POST /v1/templates/{id}/images Upload image media to a CMS-owned API draft template. templates:write
PATCH /v1/templates/{id} Update an API-created draft template. templates:write
DELETE /v1/templates/{id} Soft-delete an API-created draft template. templates:write
GET /v1/campaigns List campaigns without moving campaign logic out of backend. campaigns:read
GET /v1/campaigns/{campaign_id}/creatives Read cache-only live campaign creative previews from backend. creatives:read
GET /v1/leads List a client's CRM leads with search, status, tag, pipeline, and stage filters, or poll for changes with `updated_since`. leads:read
GET /v1/leads/lookup Find a client's lead by exact email, then phone. leads:read
GET /v1/leads/{id} Read one CRM lead, including tags, pipeline stage, and `metadata`. leads:read
POST /v1/leads Create a CRM lead, or return the existing one with `match: "email_then_phone"`. leads:write
PATCH /v1/leads/{id} Update a lead's name, email, phone, status, metadata, or pipeline stage. leads:write
PATCH /v1/leads/{id}/pipeline_stage Move a lead to a pipeline stage. leads:write
POST /v1/leads/{id}/tags Add tags to a lead by name, creating missing tags. leads:write
DELETE /v1/leads/{id}/tags/{tag} Remove a tag from a lead by name. leads:write
GET /v1/leads/{id}/notes List a lead's notes. leads:read
POST /v1/leads/{id}/notes Add a note to a lead, authored as your app. leads:write
POST /v1/leads/{id}/activities Log a call or email activity on a lead's timeline. leads:write
GET /v1/leads/{id}/contact_channels Read a lead's per-channel do-not-disturb (opt-out) state. leads:read
PATCH /v1/leads/{id}/contact_channels Set do-not-disturb for one of a lead's channels. leads:write
GET /v1/tags List the tags available to a client's leads. leads:read
POST /v1/tags Find or create a client tag by name. leads:write
GET /v1/pipelines List a client's pipelines with their stages. leads:read
GET /v1/conversations List a client's actionable conversations (lead + latest message), cursor-paginated, optionally `updated_since`. conversations:read
GET /v1/leads/{id}/messages Read a lead's SMS, email, voice, and custom-channel messages, newest first, with direction, status, and channel_label. conversations:read
POST /v1/leads/{id}/messages Send an SMS or email to a lead, or log a custom-channel message (no delivery). `Idempotency-Key` required. messages:send
GET /v1/clients/{client_id}/messaging_readiness Check whether a client can send SMS and email right now, with the reason code a send would return if not. `conversations:read` also works. messages:send
GET /v1/metrics Read metric rows with KPI computation remaining downstream. metrics:read
GET /v1/metrics/summary Read summarized metrics from the backend-owned metrics path. metrics:read
GET /v1/calendars List a client's native calendars with event types and whether each is bookable through the API. calendars:read
GET /v1/calendars/event_types/{event_type_id}/free_slots List open booking slots for an event type between `from` and `to` (up to 62 days). calendars:read
GET /v1/appointments List a client's native bookings and appointments synced from GHL, Calendly, and Google, filterable by `lead_id` and time window. calendars:read
POST /v1/appointments Book a lead into a native event type. `Idempotency-Key` is required and replays return the original appointment. appointments:write
PATCH /v1/appointments/{id} Reschedule a native appointment; returns the new appointment. appointments:write
POST /v1/appointments/{id}/cancel Cancel a native appointment. appointments:write
POST /v1/oauth/clients Register OAuth/MCP clients under the authenticated developer app. Requested or defaulted client scopes must already be present on the authenticating credential or OAuth token. oauth:clients:write
GET /oauth/authorize Start OAuth authorization (code + PKCE S256) for developer users in the OAuth client's owning account, or, for UpHex-approved public apps, for any agency user installing the app on one of their agencies. portal session + PKCE
POST /oauth/token Exchange authorization codes or refresh tokens for one-hour access tokens and 30-day rotating refresh tokens. No client_credentials grant. OAuth client authentication
POST /oauth/revoke Revoke issued OAuth access or refresh tokens at the API edge. OAuth client authentication
POST /mcp Streamable HTTP MCP edge. Auth, MCP read/write scopes, agency policy, rate limit, and audit live here; CMS executes tools. mcp:read
GET /.well-known/oauth-authorization-server OAuth metadata for authorization, token, revocation, registration, and scopes. public
GET /.well-known/oauth-protected-resource MCP protected-resource metadata pointing clients at this API edge. public

Request examples

Start with these calls when wiring a server, OAuth tool, or MCP client to the API edge.

List clients

Use `clients:read` to fetch backend-owned client records through the API edge.

curl https://api.uphex.com/v1/clients?page=1 \
  -H "X-API-Key: uphex_key_..." \
  -H "X-API-Secret: uphex_secret_..." \
  -H "X-Agency-Id: 96"

List template previews

Use `templates:read` to fetch read-only CMS-owned creative previews with safe copy, media, and pagination fields.

curl "https://api.uphex.com/v1/templates?platform=facebook&enabled=true&page=1&per_page=25" \
  -H "X-API-Key: uphex_key_..." \
  -H "X-API-Secret: uphex_secret_..." \
  -H "X-Agency-Id: 96"

Template preview detail

Template preview detail is read-only and returns client-portal creative data without provider tokens, raw metadata, prompts, or CMS-only internals.

curl https://api.uphex.com/v1/templates/12345 \
  -H "X-API-Key: uphex_key_..." \
  -H "X-API-Secret: uphex_secret_..." \
  -H "X-Agency-Id: 96"

Create draft template

Use `templates:write` with API key or legacy bearer-token authentication and an idempotency key to create one CMS-owned API draft template without duplicate retries.

curl -X POST https://api.uphex.com/v1/templates \
  -H "Content-Type: application/json" \
  -H "X-API-Key: uphex_key_..." \
  -H "X-API-Secret: uphex_secret_..." \
  -H "X-Agency-Id: 96" \
  -H "Idempotency-Key: template-draft-12345" \
  -d '{
    "name": "Disc Rehab Draft",
    "platform": "facebook",
    "headline": "Move without pain",
    "body": "Book a simple consult for disc pain."
  }'

Update draft template

Use `templates:write` to update API-created drafts only. Launched templates, UI-authored drafts, and cross-agency ids are refused by CMS.

curl -X PATCH https://api.uphex.com/v1/templates/12345 \
  -H "Content-Type: application/json" \
  -H "X-API-Key: uphex_key_..." \
  -H "X-API-Secret: uphex_secret_..." \
  -H "X-Agency-Id: 96" \
  -d '{"headline":"Move better this week"}'

Upload draft template image

Use `templates:write` to attach one HTTPS image URL or one base64 image to an API-created draft. API key and legacy bearer-token authenticated requests may include an idempotency key for safe retries. JPG, PNG, GIF, and SVG images are supported. Decoded images are limited to 10 MB; this route has a 15 MB body allowance so encoded images fit while other JSON endpoints stay at 1 MB.

curl -X POST https://api.uphex.com/v1/templates/12345/images \
  -H "Content-Type: application/json" \
  -H "X-API-Key: uphex_key_..." \
  -H "X-API-Secret: uphex_secret_..." \
  -H "X-Agency-Id: 96" \
  -d '{
    "image_url": "https://cdn.example.com/template-image.png",
    "image_orientation": "square"
  }'

Delete draft template

Use `templates:write` to soft-delete an API-created draft. The response echoes a stable deleted status.

curl -X DELETE https://api.uphex.com/v1/templates/12345 \
  -H "X-API-Key: uphex_key_..." \
  -H "X-API-Secret: uphex_secret_..." \
  -H "X-Agency-Id: 96"

Campaign creative previews

Use `creatives:read` to fetch read-only, cache-only live campaign previews for client portals. Missing or stale cache is explicit; this endpoint never triggers provider syncs.

curl "https://api.uphex.com/v1/campaigns/456/creatives?client_id=123" \
  -H "X-API-Key: uphex_key_..." \
  -H "X-API-Secret: uphex_secret_..." \
  -H "X-Agency-Id: 96"

Create lead

Use `leads:write` with an idempotency key so network retries do not duplicate accepted writes. `match: "email_then_phone"` returns the client's existing lead instead of creating a duplicate.

curl -X POST https://api.uphex.com/v1/leads \
  -H "Content-Type: application/json" \
  -H "X-API-Key: uphex_key_..." \
  -H "X-API-Secret: uphex_secret_..." \
  -H "X-Agency-Id: 96" \
  -H "Idempotency-Key: lead-12345" \
  -d '{
    "client_id": 123,
    "full_name": "Ada Lovelace",
    "email": "ada@example.com",
    "phone": "555-0100",
    "match": "email_then_phone"
  }'

Incremental lead sync

Poll for leads changed since your last sync as a backstop to webhooks. Results come oldest change first; follow `meta.next_cursor` (send it as `cursor` with the same `updated_since`) until it is `null`, upsert each lead by `id`, then poll again from a few minutes before the largest `updated_at` you stored.

curl "https://api.uphex.com/v1/leads?client_id=123&updated_since=2026-10-22T18:00:00Z&per_page=100" \
  -H "X-API-Key: uphex_key_..." \
  -H "X-API-Secret: uphex_secret_..." \
  -H "X-Agency-Id: 96"

Tag a lead

Tags are referenced by name; missing tags are created. The response is the updated lead.

curl -X POST https://api.uphex.com/v1/leads/987/tags \
  -H "Content-Type: application/json" \
  -H "X-API-Key: uphex_key_..." \
  -H "X-API-Secret: uphex_secret_..." \
  -d '{"client_id": 123, "tags": ["hot-lead"]}'

Find a free slot

Use `calendars:read`. List calendars to find a bookable event type, then ask for its open slots.

curl "https://api.uphex.com/v1/calendars/event_types/20/free_slots?client_id=123&from=2026-11-02&to=2026-11-09&timezone=America/Denver" \
  -H "Authorization: Bearer uphex_access_..."

Book an appointment

Use `appointments:write` with an `Idempotency-Key`; retries with the same key return the original appointment.

curl -X POST https://api.uphex.com/v1/appointments \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer uphex_access_..." \
  -H "Idempotency-Key: closebot-booking-8812" \
  -d '{
    "event_type_id": 20,
    "lead_id": 42,
    "starts_at": "2026-11-05T16:00:00Z",
    "timezone": "America/Denver",
    "notes": "Booked by CloseBot"
  }'

Send an SMS to a lead

Use `messages:send`. `client_id` is required and must belong to the token's agency. `Idempotency-Key` is required; a retry with the same key replays the first outcome instead of sending again.

curl -X POST https://api.uphex.com/v1/leads/789/messages \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: closebot-msg-9f1c" \
  -d '{"client_id": 123, "type": "sms", "body": "Hi Ada, are you free Tuesday at 3pm?"}'

Log a custom-channel message

`type: custom` records a message on a channel UpHex does not deliver (for example your own chat widget). Inbound custom messages fire `contact_replied` workflows. Nothing is sent.

curl -X POST https://api.uphex.com/v1/leads/789/messages \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: closebot-chat-5521" \
  -d '{
    "client_id": 123,
    "type": "custom",
    "direction": "inbound",
    "channel_label": "CloseBot Chat",
    "body": "Can I book for Tuesday?",
    "external_id": "cb-msg-5521",
    "occurred_at": "2026-10-22T15:04:05Z"
  }'

Check messaging readiness

Use before a send to explain why it would fail (no sender number, A2P not approved, sending domain).

curl "https://api.uphex.com/v1/clients/123/messaging_readiness" \
  -H "Authorization: Bearer <access_token>"

Metrics summary

Use `metrics:read`; KPI computation and metric storage stay in the downstream backend.

curl "https://api.uphex.com/v1/metrics/summary?group_by=client_id&start_date=2026-05-01&end_date=2026-05-29" \
  -H "X-API-Key: uphex_key_..." \
  -H "X-API-Secret: uphex_secret_..." \
  -H "X-Agency-Id: 96"

Register OAuth/MCP client

Use `oauth:clients:write` to create delegated OAuth/MCP clients under the same developer app. The generated client secret is returned once.

curl -X POST https://api.uphex.com/v1/oauth/clients \
  -H "Content-Type: application/json" \
  -H "X-API-Key: uphex_key_..." \
  -H "X-API-Secret: uphex_secret_..." \
  -d '{
    "client_name": "Reporting MCP Client",
    "redirect_uris": ["http://localhost:8787/oauth/callback"],
    "scope": "clients:read metrics:read mcp:read"
  }'

MCP tools/list

Use a bearer token or API key with `mcp:read` for read-only tools. Legacy `mcp:tools` is preserved as deliberate full MCP access (`mcp:read` plus `mcp:write`). Agency access remains explicit at the edge.

curl -X POST https://api.uphex.com/mcp \
  -H "Authorization: Bearer uphex_access_..." \
  -H "Content-Type: application/json" \
  -H "X-Agency-Id: 96" \
  -H "Mcp-Protocol-Version: 2025-03-26" \
  -d '{"jsonrpc":"2.0","id":"1","method":"tools/list","params":{}}'

MCP template_details

After discovering tools, call read tools such as `template_details` with the same server-side auth, agency, and protocol headers. Do not log or share real bearer tokens, API secrets, provider tokens, or returned customer data.

curl -X POST https://api.uphex.com/mcp \
  -H "Authorization: Bearer uphex_access_..." \
  -H "Content-Type: application/json" \
  -H "X-Agency-Id: 96" \
  -H "Mcp-Protocol-Version: 2025-03-26" \
  -d '{"jsonrpc":"2.0","id":"template-details-1","method":"tools/call","params":{"name":"template_details","arguments":{"template_id":12345}}}'

Authentication

Protected `/v1` routes and `/mcp` accept exactly one authentication method per request.

  • API key headers: `X-API-Key: uphex_key_...` plus `X-API-Secret: uphex_secret_...`, issued per developer app in the portal. The secret is shown once.
  • API key over HTTP Basic: `Authorization: Basic base64(api_key:api_secret)`.
  • Bearer: `Authorization: Bearer <token>` with an OAuth access token (`uphex_access_...`) or a one-hour token from `POST /v1/tokens`.
  • Bearer tokens and legacy tokens carry the scopes of the OAuth grant or API key they came from.
  • Mixing bearer auth with API key headers, or sending an auth header twice, returns `401`.
  • `GET /v1/health` is the cheapest way to confirm credentials; it returns the app id, name, environment, and granted scopes.

OAuth flow

OAuth is authorization code + PKCE (S256) with confidential clients. It is meant for developer users in the developer account that owns the OAuth client.

  • Register a client in the portal or with `POST /v1/oauth/clients` (`oauth:clients:write`). The `uphex_oauth_secret_...` secret is returned once.
  • Redirect URIs must be `https`, or `http` only for localhost/loopback, with no userinfo or fragment, and they match exactly.
  • Send the user to `/oauth/authorize`; they must be signed in to the Developer Portal in the owning account. Requested scopes must be a subset of the client's scopes (all client scopes when omitted).
  • The authorization code is single-use and expires after 10 minutes. Tokens are bound to one agency resolved from the approving user's account and the app's allowed agencies.
  • Exchange the code at `/oauth/token` with `redirect_uri` and `code_verifier`. Access tokens last 1 hour; refresh tokens last 30 days and rotate on every refresh.
  • Only `authorization_code` and `refresh_token` grants exist; there is no `client_credentials` grant. Server-to-server integrations should use API keys.
  • Token and revoke errors use OAuth-style bodies such as `{"error":"invalid_grant"}`; client authentication failures return `401` `invalid_client`.
  • `Idempotency-Key` works with OAuth access tokens on every write that accepts it. On edge-stored routes keys are scoped to the OAuth client and the token's agency, so a refreshed token keeps seeing them; `POST /v1/leads/{id}/messages` and `POST /v1/appointments` require a key and crm-service holds it per developer app.

Publishing your app

Every app starts private: only your developer account's own agency and agencies UpHex granted can install it. To let any agency install it through OAuth consent, submit it for UpHex review.

  • Register an OAuth client with every scope and redirect URI the app needs, including at least one `https` redirect URI.
  • On the app page in the portal, fill in the public listing (description up to 1,000 characters, `https` homepage URL, `https` privacy policy URL, and a support email) and choose Submit for review. The app shows `in review`.
  • UpHex approves or rejects the submission. Approval makes the app `public` and records the scopes and redirect URIs of its active OAuth clients as the approved snapshot. A rejection shows the reason; fix it and submit again.
  • Agencies can only consent to scopes and redirect URIs in the approved snapshot. Adding a scope or redirect URI to a public app's OAuth clients marks it `changes pending review`: consent requesting the new scope returns `invalid_scope` (an unapproved `redirect_uri` is refused without a redirect) until UpHex re-approves, while already-approved scopes keep working.
  • Removing a scope, revoking an OAuth client, and rotating a client secret never trigger re-review.
  • UpHex may delist a public app. Delisting blocks new installs and re-consent by other agencies (your own and UpHex-granted agencies can still install it, as with a private app); existing installs and their tokens keep working until an agency revokes them.
  • Once public, webhooks for an agency are delivered through that agency's installation and its granted scopes. To keep receiving events for your own agency after approval, connect it once through OAuth consent.

Rate limits

Authenticated `/v1` and `/mcp` traffic is counted per developer app in fixed one-minute windows; all of an app's API keys and OAuth tokens share one counter.

  • The default limit is 120 requests per minute per app; the per-app maximum is 1,000.
  • Every counted response includes `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` (Unix seconds).
  • Over the limit, the edge returns `429` with `Retry-After` in seconds and `{"errors":{"base":["Rate limit exceeded"]}}`.
  • `POST /v1/tokens` is limited to 120 attempts per minute per source IP and 30 per presented API key.
  • `/oauth/token` and `/oauth/revoke` are limited to 120 attempts per minute per source IP and 60 per presented client id.

Pagination

List routes are page-numbered and report totals in `meta`.

  • Send `page` (1-based). Page size is fixed by the owning service, except `GET /v1/templates` and `GET /v1/leads`, which accept `per_page` from 1 to 100 (leads default to 25).
  • Read `meta.page`, `meta.per_page`, `meta.total_entries`, and `meta.total_pages`, and stop when `page` reaches `total_pages`.
  • Unsupported query parameters, including `per_page` on routes that do not accept it, return `400`.
  • `GET /v1/leads` with `updated_since` or `cursor` is keyset-paginated instead: read `meta.next_cursor` and `meta.has_more`, and send `next_cursor` back as `cursor` until it is `null`. `page` cannot be combined with either.
  • `GET /v1/conversations` and `GET /v1/leads/{id}/messages` use `limit` and an opaque `cursor`: send `meta.next_cursor` back as `cursor` until it is `null`.

Leads

`/v1/leads` is the single public lead resource, backed by UpHex's CRM (the same leads shown in the CRM inbox, pipelines, and workflows). Tags, notes, activities, pipeline stage, and channel opt-outs nest under `/v1/leads/{id}`.

  • Every lead route requires `client_id` (query string on reads and deletes, JSON body on writes). A `client_id` outside the authorized agency returns `404` `client_not_found`.
  • Lead ids are CRM lead ids. Ids returned by the pre-CRM `/v1/leads` (before October 2026) are not valid; look leads up again with `GET /v1/leads/lookup`.
  • Lead fields: `id`, `client_id`, `full_name`, `email`, `phone`, `status`, `pipeline_id`, `pipeline_stage_id`, `campaign_id`, `campaign_name`, `created_time`, `source`, `updated_at` (ISO 8601 UTC), `tags`, and `metadata`. `ghl_tags` is a deprecated alias of `tags` and will be removed in a later release.
  • List filters: `search` (partial name, email, or phone), `email` (exact), `status`, `tag`, `pipeline_id`, `pipeline_stage_id`, and `campaign_id`; page with `page` and `per_page` (1-100, default 25).
  • Incremental sync (a backstop to webhooks): `updated_since` (ISO 8601; inclusive, second precision; no offset means UTC, a bare date means midnight UTC; percent-encode a `+` offset as `%2B`) returns leads changed at or after that time, ordered by `updated_at` then `id`, and pages with `meta.next_cursor` / `cursor` instead of `page`. A lead updated while you page is returned again on a later page, so upsert by `id`; poll from a few minutes before the largest `updated_at` you stored. Removed leads are not returned.
  • `POST /v1/leads` creates one lead and returns `201`. Send `match: "email_then_phone"` to return the client's existing lead (`200`, `meta.matched: true`) when one has the same email, or else the same phone, instead of creating a duplicate.
  • `PATCH /v1/leads/{id}` updates `full_name`, `email`, `phone`, `status`, and `metadata` (replaced as a whole). Sending `pipeline_stage_id` (and optionally `pipeline_id` to switch pipelines) also moves the stage; the stage move runs first.
  • Tags are referenced by name. `POST /v1/leads/{id}/tags` with `{"tags": [...]}` creates any missing tags; `DELETE /v1/leads/{id}/tags/{tag}` removes one by name (`404` if the client has no such tag). Both return the updated lead.
  • Notes are authored as your developer app. Activities accept `activity_type` `call` or `email`, a `description`, optional `metadata`, and optional `occurred_at`.
  • `/v1/leads/{id}/contact_channels` reads and sets do-not-disturb per channel: `email`, `sms`, `marketing_sms`, or `transactional_sms`.
  • Leads created through the API have `source` `public_api` in UpHex.

Calendars and appointments

`/v1/calendars` and `/v1/appointments` read and book crm-service calendars for one client of the credential's agency. Reads need `calendars:read`; booking, rescheduling, and canceling need `appointments:write`.

  • Every `client_id` must belong to the credential's agency; other ids return `404` `client_not_found`.
  • Appointment ids are strings: `bk_<id>` is a native UpHex booking and `la_<id>` is an appointment synced from GHL, Calendly, or Google.
  • Appointments carry `provider` (`native`, `ghl`, `calendly`, `google`) and `status` (`scheduled`, `pending`, `rescheduled`, `cancelled`, `no_show`, `showed`). Times are UTC.
  • v1 limit: only active, free event types on native UpHex calendars can be booked. Anything else, including GHL or Calendly calendar ids, returns `422` `calendar_not_bookable`; synced appointments are read-only (`422` `appointment_read_only`).
  • `POST /v1/appointments` requires `Idempotency-Key` (up to 200 characters) with API keys and OAuth tokens. A replay returns `200` with `X-Idempotent-Replay: true`; reusing a key for a different booking returns `409` `idempotency_key_conflict`.
  • Rescheduling creates a new appointment whose `previous_appointment_id` is the moved one.
  • Booking-domain errors use the normalized error list `{"errors":[{"code","title","detail","source"}],"request_id"}` with a stable `code`: `409` `slot_unavailable` or `idempotency_key_conflict`; `422` `calendar_not_bookable`, `outside_availability`, `lead_missing_contact`, `range_too_large`, `appointment_read_only`, `appointment_not_reschedulable`, or `appointment_not_cancelable`; `404` `event_type_not_found`, `lead_not_found`, or `appointment_not_found`. Edge validation, scope, and `Idempotency-Key` failures use `errors.base`.

Conversations & messages

Conversations, lead message history, sends, and messaging readiness are proxied to crm-service and messaging-service. `client_id` is required (query string on reads, JSON body on `POST`) and must belong to the authorized agency for every credential type; otherwise `404` `Client not found`.

  • `GET /v1/conversations` lists the client's actionable conversations: the latest message is inbound, or outbound within the last 72 hours, or the lead is new within 72 hours. `limit` 1-100 (default 25); follow `meta.next_cursor` until it is `null`.
  • `updated_since` (ISO 8601 with offset) keeps conversations whose `updated_at` is at or after it. It filters each page, so a page can hold fewer than `limit` items; keep following `next_cursor`.
  • `GET /v1/leads/{id}/messages` returns newest first: `channel` (`sms`, `email`, `voice`, `custom`), `limit` 1-100 (default 50), `cursor`. History reaches the 500 most recent messages.
  • `POST /v1/leads/{id}/messages` with `type: sms` (`body`) or `type: email` (`body`, `subject`, optional `html`, `from_email`) sends through UpHex messaging and records the message: `201` when new.
  • `type: custom` (`direction`, `body`, `channel_label` up to 40 characters, optional `external_id` up to 200, `occurred_at`) only logs the message; nothing is delivered. Inbound custom messages fire `contact_replied` workflows. `occurred_at` is returned in UTC; history is ordered by when UpHex recorded the message.
  • `Idempotency-Key` (1-200 letters, numbers, `.`, `_`, `:`, `-`) is required on this `POST` for API keys and OAuth tokens alike; crm-service, not the edge key store, holds it. A repeat returns `200` with `meta.idempotent_replay: true` and `X-Idempotent-Replay: true`, a failed send replays the same error, and a key reused for a different send returns `409` `idempotency_key_conflict`.
  • Custom messages dedupe on `external_id` when you send one, otherwise on the key. A repeat whose stored message differs (direction, body, or channel label) returns `409` `idempotency_key_conflict` (key) or `external_id_conflict` (your `external_id`, also returned when it is already used on another lead).
  • Keys and external ids are scoped to your app, so two apps on one agency never collide. `external_id` is shown only for messages your app logged with one.
  • A refused send keeps crm-service's code and status in the normalized error list (`code`, `title`, `detail`, `source: crm`, plus `reason`, `retryable`, `lead_message_id` when known): `invalid_params` 422, `client_not_found` / `lead_not_found` 404, `lead_missing_phone` / `lead_missing_email` / `invalid_from_email` 422, `ghl_routed_contact` 409, `no_from_number_provisioned` / `a2p_campaign_not_ready` / `sms_suppressed` / `email_suppressed` / `sending_domain_unavailable` 422, `sms_not_enabled` 403, `insufficient_credits` 402, `send_failed` 502 (check `retryable`), `idempotency_key_in_progress` 409, `idempotency_key_conflict` 409.
  • `GET /v1/clients/{client_id}/messaging_readiness` (`messages:send` or `conversations:read`) reports `sms.ready` / `sms.reason` (`no_from_number_provisioned` or `a2p_campaign_not_ready`, raw reason in `detail`) and the email sending domain with its status. Lead opt-outs, GoHighLevel routing, plan entitlement, and wallet balance are checked only at send time. A failed read returns `502` `messaging_readiness_unavailable`.
  • Sends count against the app's normal per-minute rate limit (default 120, see Rate limits).

Validation

The API edge validates public query params, scopes, lead write payloads, and draft template write payloads before proxying downstream.

  • `agency_id` is not accepted as a query param; single-agency apps use the developer account agency by default.
  • Multi-agency apps can send `X-Agency-Id`, which must be a bounded positive integer allowed by the app policy.
  • Policy headers such as `X-Agency-Id` and `Idempotency-Key` must be sent at most once.
  • API key ids, API secrets, API Basic authentication, and bearer tokens are shape-bounded before authentication lookup or token verification.
  • `group_by` is required for `GET /v1/metrics` and must be `client_id` or `campaign_id`.
  • Campaign creative preview ids must be bounded positive integers; the optional `client_id` filter must match the campaign's owning client or backend returns not found.
  • Template preview filters are limited to `platform`, `category_id`, `enabled`, `page`, `per_page`, `q`, and `query`; template ids must be bounded positive integers.
  • Template draft create requires `name` and `platform`; update requires a non-empty object. Unknown template body fields, including unsupported nested `ads` and `adsets`, are rejected at the edge.
  • Template draft image upload accepts exactly one of `image_url` or `image_base64`; URLs must be HTTPS, base64 data URLs may declare only image/jpeg, image/png, image/gif, or image/svg+xml, decoded images are limited to 10 MB, and the upload route has a 15 MB route-specific request body allowance.
  • Date filters must use ISO 8601 format such as `2026-05-29`.
  • When both `start_date` and `end_date` are sent on metrics routes, `start_date` must be on or before `end_date`.
  • String filters and lead fields are capped at 255 characters; lead ids must be bounded positive integers.
  • Comma-separated id filters can contain up to 100 bounded positive integer values.
  • Date, integer, and comma-separated id filters must be scalar values; structured objects and arrays are rejected at the edge.
  • Public query and lead-write integer fields are bounded before validation parses them or forwards requests downstream.
  • Lead creation requires `client_id` and an `email` or `phone`; `email` must be a valid address. Bulk lead creation is not supported.
  • External API, OAuth, and MCP request bodies are limited to 1 MB before parsing and during parser reads, including clients that omit `Content-Length`, except `POST /v1/templates/{id}/images`, which uses the same 15 MB route-specific cap in both places.
  • MCP requests must be a single JSON-RPC object with `jsonrpc: "2.0"`; ids may be strings (up to 128 bytes) or JSON-safe integers, and null ids, fractional ids, array `params`, and batches are rejected before proxying.
  • Parser-level malformed request and unsupported media-type failures use the same public `errors.base` envelope as controller-level validation failures.
  • Malformed or duplicated `Content-Length` headers are rejected before request parsing.
  • OAuth token and revocation requests reject malformed or oversized authorization codes, refresh tokens, revocation tokens, PKCE verifiers, and redirect URIs before credential material reaches grant lookup paths.
  • Protected public API routes reject mixed authentication methods, such as bearer auth plus `X-API-Key` headers.
  • Portal-created control-plane display fields are bounded before storage: apps, API keys, OAuth/MCP clients, webhook endpoints, developer users, and accounts fail validation when names, URLs, or descriptions are too large.
  • Registered OAuth/MCP redirect URI lists are trimmed, deduplicated, and bounded before invalid URI errors are returned.

Responses

Successful responses always use the same envelope while domain data remains backend-owned.

  • `data` contains the resource payload returned by the owning service.
  • `meta.resource` identifies the public resource.
  • Safe pagination and date range metadata are preserved with redacted and bounded values.
  • Internal downstream fields such as agency ids, SQL, or debug keys are filtered.
  • Template preview and write echo data is limited to public-safe copy, category, availability, ad/adset preview rows, media URLs or public references, deleted status, and timestamps.
  • Campaign creative preview data is read-only and cache-only: backend returns public-safe campaign, adset, ad, creative, media, copy, CTA, status, and cache metadata without provider tokens, targeting, review feedback, object story specs, or asset feed specs.

Creative Writes (beta)

Creative write beta access is conservative: it creates and changes only CMS-owned API drafts for the resolved agency, with explicit scopes, request logs, and reversible stop controls.

  • `templates:write` is required for REST create, update, image upload, and delete endpoints; `templates:read` remains enough only for preview reads.
  • `mcp:write` exposes the external draft-template write tools (`template_create`, `template_create_direct`, `template_update` by `section`, `template_add_image`, and `template_delete`) while `mcp:read` stays read-only.
  • REST and MCP creative writes are draft-only: launched templates, non-drafts, and UI-authored drafts cannot be changed through the public API and return normalized errors instead.
  • No public creative write route launches, publishes, activates, or reaches CampaignBuilders/backend campaign launch endpoints.
  • `POST /v1/templates` and `POST /v1/templates/{id}/images` support `Idempotency-Key` for API key, legacy bearer-token, and OAuth access token requests. `PATCH /v1/templates/{id}` and `DELETE /v1/templates/{id}` should be retried only after checking the latest template state or request log.
  • Image uploads accept one HTTPS URL or one base64 payload. JPG, PNG, GIF, and SVG are supported, decoded images are limited to 10 MB, and the image route uses a 15 MB encoded-body allowance.
  • Delete means soft-delete of an API-created draft. It is the documented rollback path for removing beta-created draft assets without affecting launched campaigns.
  • Every write is request-logged with `X-Request-ID`, resolved agency, auth mode, downstream CMS status, idempotency state when present, and public caller attribution forwarded to CMS audit paths.
  • Rate limits apply at the developer app/API key or OAuth credential boundary and return `429` with `Retry-After` plus rate-limit headers.
  • Rollback controls stay at the edge: revoke the API key/OAuth client or pause/suspend/archive the app to stop future REST and MCP writes without deleting downstream audit history.

Errors

Public errors are normalized so integrations get stable status codes and readable details.

  • `400` means the API edge rejected an invalid public request, agency header, or idempotency key.
  • `401` means authentication failed: missing, malformed, mixed, revoked, or expired credentials, or an inactive app or account. On protected `/v1` routes and `/mcp` the body is always `{"errors":{"base":["Unauthorized"]}}` with a `WWW-Authenticate` challenge; `POST /v1/tokens` returns `Invalid API credentials`, and `/oauth/token` and `/oauth/revoke` return `{"error":"invalid_client"}`.
  • `403` means the credential is missing the required scope (`missing_scope`), the app may not access the resolved agency (`agency_not_allowed`), or the owning service refused the request.
  • `404` means the owning service did not find the record for the resolved agency, or `GET /v1/{resource}` named an unknown resource (`unknown_resource`).
  • `405` is returned for `GET /mcp` and `DELETE /mcp`.
  • `413` means the request body exceeded 1 MB (15 MB for template image uploads).
  • `429` means the developer app rate limit was exceeded for API key or OAuth traffic.
  • API-edge validation, authentication, scope, idempotency, and rate-limit failures use `errors.base` or field-keyed error arrays.
  • Downstream rejections use a normalized error list with `code`, `title`, `detail`, and `source`; downstream 4xx statuses are preserved and downstream 5xx statuses become `502` with code `downstream_unavailable`. `/mcp` passes CMS 4xx JSON-RPC bodies through (redacted) and normalizes only 5xx.
  • `409` on template writes means CMS found the template for the resolved agency but it is not an API-created draft that can be modified through the public API.
  • Stable request-log error codes include `unauthorized`, `invalid_request`, `missing_scope`, `missing_agency_id`, `invalid_agency_id`, `ambiguous_agency_id`, `agency_not_allowed`, `rate_limited`, `invalid_idempotency_key`, `idempotency_key_processing`, `idempotency_key_conflict`, `downstream_rejected`, `backend_unavailable`, `cms_unavailable`, `downstream_timeout`, and `unknown_resource`.
  • `POST /v1/tokens` also returns `429` with `Retry-After` when legacy credential exchange attempts exceed IP or presented-key limits.
  • `/oauth/token` and `/oauth/revoke` also return `429` with `Retry-After` when client-auth attempts exceed OAuth endpoint limits.
  • `502` means an owning downstream service is unavailable.
  • `504` means an owning downstream service timed out.
  • Proxy/downstream error bodies include `request_id`, matching the `X-Request-ID` header.

Request IDs

Every public API response includes `X-Request-ID`, and the same value is forwarded to downstream UpHex services.

  • Keep this id in your logs for support and debugging.
  • The developer portal request log is searchable by request id, accepts pasted smoke request-id lists, and is filterable by agency id and status family.
  • Portal request-log filters are bounded, and wildcard characters are treated literally.
  • Request detail pages show resolved agency, auth mode, downstream status, rate-limit state, and webhook enqueue state.
  • `POST /v1/tokens` exchange attempts are logged without storing API secrets.
  • OAuth token exchange and revocation attempts are logged without storing client secrets, authorization codes, access tokens, or refresh tokens.
  • Known API key ids on failed `/v1/tokens` exchanges are scoped to their owning app for portal debugging.
  • Sensitive query-string and metadata values are bounded and redacted before request logs are stored, including malformed query strings that cannot be decoded safely.
  • Portal audit metadata is also redacted and bounded before storage so secret, token, password, and digest values are not displayed in control-plane history.
  • UpHex also forwards `X-UpHex-Edge-Request-ID` to owning services for internal tracing and a bounded public caller id to CMS for draft-template audit attribution.
  • Custom downstream headers are limited to valid HTTP token names and bounded values without CR/LF or NUL bytes.

Support handoff

External developer support starts from the API edge request log, not raw backend or CMS internals.

  • Send UpHex the `X-Request-ID`, HTTP method, path, timestamp, app name, agency id, and the public error body.
  • Use `/portal/requests` to find matching requests, including pasted smoke request-id lists, and confirm auth mode, resolved agency, downstream service, rate-limit state, idempotency state, and webhook enqueue state.
  • Do not send API secrets, OAuth client secrets, access tokens, refresh tokens, webhook signing secrets, or provider tokens in support messages.
  • UpHex can trace the edge request and downstream delegation from request ids while backend, CMS, and connector keep ownership of their domain data.

Credential lifecycle

The developer portal owns credential operations so external developers do not need direct access to backend services.

  • API key secrets, OAuth client secrets, and webhook signing secrets are shown once and stored only as digests.
  • API keys can have expiration dates and can be rotated while keeping the same public key id.
  • OAuth/MCP client secrets can be rotated while keeping the same public client id.
  • Webhook signing secrets can be rotated while keeping endpoint history and delivery records.
  • Revoking an API key, OAuth/MCP client, or OAuth token session stops future edge authentication without deleting audit history.
  • Credential create, rotate, revoke, token-session revoke, and successful portal sign-in actions are recorded in portal audit logs.

Preview handoff

Preview bundles are generated by the API edge so external developers receive only edge-owned access.

  • `mix uphex_api.preview_sandbox` creates or reuses a developer account, owner user, and sandbox app for a known agency.
  • `UPHEX_API_PREVIEW_PASSWORD` must be explicitly set, must be 12-128 bytes, and cannot use the placeholder from `.env.example`.
  • `UPHEX_API_PREVIEW_BASE_URL` must be an absolute `http` or `https` origin; userinfo, paths, query strings, and fragments are rejected.
  • Existing preview users are reactivated as owners and updated to the configured preview password so the printed portal sign-in details work.
  • `UPHEX_API_PREVIEW_SCOPES` can narrow the generated API key and OAuth/MCP client to the least-privilege scopes needed by the preview.
  • Generated API secrets and OAuth client secrets are printed once, stored only as digests, and should be shared through a secure channel.
  • The handoff output includes portal sign-in details, docs, OpenAPI, request-log URLs, support contact, agency id, and granted scopes.
  • A ready-to-run `scripts/smoke-preview.sh` command is printed only when the generated credentials include the scopes exercised by the smoke script.

Edge stop controls

UpHex can stop developer traffic at the API edge without deleting downstream-owned campaign, lead, metric, or provider-token data.

  • Public sign-up can store a bounded requested agency id for review without granting access.
  • Portal app allow-list agency ids are bounded before parsing and must already be granted to the developer account.
  • Suspending a developer account stops portal sessions, API keys, OAuth clients, issued OAuth access tokens, and pending webhook deliveries from authenticating or sending.
  • Paused, suspended, and archived apps are shown as traffic-blocked in the portal.
  • Blocked apps keep credentials, OAuth clients, webhooks, request logs, and audit history visible for review.
  • When an app is blocked, public API and MCP requests are rejected at the edge and are not forwarded to backend or CMS.

MCP gateway

`POST /mcp` accepts Streamable HTTP JSON-RPC requests and proxies them to CMS-owned MCP tool execution.

  • Requests require `mcp:read`, `mcp:write`, or legacy `mcp:tools`. The agency comes from `X-Agency-Id`, an OAuth token's bound agency, or the app's single default agency, using the same rules as the REST routes.
  • `mcp:read` exposes read tools such as `template_search`, `template_details`, and `campaign_details` with `view=funnel`.
  • `mcp:write` is explicit write MCP access and is forwarded to CMS as read plus write tool intent so write-capable integrations can still discover and call read helpers.
  • With `mcp:write`, external callers can use only draft-template write tools such as `template_create`, `template_create_direct`, `template_update` by `section`, `template_add_image`, and `template_delete`.
  • External MCP write tools are draft-only: they can create, update, upload images to, and soft-delete API-created drafts, but they cannot mutate launched/non-draft templates.
  • Launch/provider mutation tools such as `campaign_launch` and `launch_template_as_adset` are not exposed on the external public API MCP write surface.
  • Legacy `mcp:tools` is preserved as deliberate full MCP tool access (`mcp:read` plus `mcp:write`) for backwards compatibility.
  • The API edge derives CMS MCP scope intent only from the authenticated API credential or OAuth access token; client-supplied internal scope headers are ignored.
  • `Mcp-Protocol-Version`, when provided, must be one of `2024-11-05`, `2025-03-26`, or `2025-06-18`.
  • `X-Agency-Id` and `Mcp-Protocol-Version` must each be sent at most once.
  • Each request is one JSON-RPC object (batches are rejected). Ids are limited to strings up to 128 bytes or JSON-safe integers; null ids are rejected. Method names are bounded to 128 bytes and cannot contain control characters. `params` must be an object when present.
  • The developer app must be allowed to access the requested agency.
  • Clients can discover OAuth and MCP metadata from `/.well-known/oauth-authorization-server` and `/.well-known/oauth-protected-resource`.
  • Authorization-server discovery includes the developer documentation URL plus supported token and revocation client authentication methods.
  • Gateway errors include `request_id`, and portal request logs show the resolved agency.
  • CMS 5xx responses are normalized into stable `502` gateway errors instead of exposing raw CMS bodies.
  • The gateway is stateless: `Mcp-Session-Id` is neither issued nor forwarded from CMS.
  • `GET /mcp` and `DELETE /mcp` return `405` because the edge is stateless.

Idempotency

`POST /v1/leads`, `POST /v1/leads/{id}/notes`, `POST /v1/leads/{id}/activities`, `POST /v1/templates`, and `POST /v1/templates/{id}/images` support `Idempotency-Key` for API key, legacy bearer-token, and OAuth access token requests so integrations can retry safely after network failures.

  • Use a unique key per logical write and reuse it only when retrying that same write.
  • Keys are kept for 24 hours; after that the same key starts a fresh request. API-key requests scope keys to the API key that sent them; OAuth access token requests scope them to the OAuth client and the token's agency, so refreshed or rotated tokens of the same client share keys while other apps, clients, and agencies never see them.
  • Responses with status 200-499 are stored and replayed with `X-Idempotent-Replay: true`; 5xx responses release the key so a retry runs again.
  • Requests that fail body or scope validation are rejected before a key is claimed.
  • Agency errors (`X-Agency-Id` `400` or `403`) are returned before a key is claimed, and the resolved agency is part of the request fingerprint.
  • A repeated key while the first request is still running returns `409` (`idempotency_key_processing`).
  • Keys are capped at 255 characters and may contain letters, numbers, dots, underscores, colons, or hyphens.
  • A repeated key with the same payload replays the original public response.
  • A repeated key with a different payload returns `409`.
  • Template update and delete requests do not replay from idempotency storage; read the template or request log before retrying those operations.
  • Expired idempotency records are released before a fresh matching request is claimed.
  • Oversized public responses are not stored for replay; the key is released so a retry can re-run instead of staying stuck as processing.
  • `POST /v1/leads/{id}/messages` is different: `Idempotency-Key` is required for API keys and OAuth tokens, is held by crm-service rather than the edge key store, and replays the first send's outcome (see Conversations & messages).

Webhooks

Active matching webhook endpoints receive developer-facing events that this API edge can safely derive from accepted public writes.

  • `lead.created` is sent by UpHex's CRM once per created lead, whatever created it (`POST /v1/leads`, forms, ads, or the UpHex app). A `POST /v1/leads` that matches an existing lead sends nothing.
  • Endpoints are managed per developer app in the portal and subscribe to one or more supported events. The signing secret (`whsec_...`) is shown once and can be rotated.
  • Each delivery is a JSON `POST` of `{"event", "data"}` with `UpHex-Event`, `UpHex-Delivery-Id`, `UpHex-Signature-Timestamp`, and `UpHex-Signature: v1=<hex>` headers.
  • Signature: HMAC-SHA256 over `<timestamp>.<raw body>`, keyed with the lowercase hex SHA-256 digest of the signing secret, hex-encoded lowercase.
  • Any `2xx` response within 10 seconds marks the delivery delivered. Other statuses, connection errors, and timeouts are retried automatically, for at most 5 attempts, with backoff of 17s, 19s, 23s, and 31s after successive failures; the portal shows the same next-retry time.
  • Each attempt is re-signed with a fresh timestamp but keeps the same `UpHex-Delivery-Id`. Delivery is at-least-once and unordered, so de-duplicate on that id.
  • Deliveries are cancelled without retry when the endpoint is disabled, the app or developer account is inactive, or the URL fails the safety check. Failed deliveries can be retried from the portal; each manual retry starts a new run of up to 5 attempts.
  • Webhook endpoints may subscribe only to supported public events: `lead.created`, `lead.updated`, `lead.tag_added`, `lead.tag_removed`, `message.inbound`, `message.outbound.status`, `appointment.created`, `appointment.rescheduled`, `appointment.canceled`, and `app.uninstalled`.
  • CRM events (`lead.*` from the CRM, `message.*`, `appointment.*`) are delivered for an agency only to endpoints that list the event explicitly, owned by apps authorized for that agency whose scopes cover the event: `leads:read` for `lead.*`, `conversations:read` for `message.*`, and `calendars:read` for `appointment.*`. Partner apps need an active installation for the agency whose granted scopes cover the event; other apps need the agency on the developer account or an operator agency installation plus an active API key or OAuth client holding the scope. An endpoint with no events selected keeps receiving only app-targeted events.
  • CRM event `data` is `{event_id, type, agency_id, client_id, occurred_at, data}`. `event_id` is a UUID that is stable for the source change even across separate deliveries; de-duplicate business effects on it.
  • `message.inbound` carries `data.channel` (`sms`, `email`, or `custom`); `message.outbound.status` carries `data.status` (`delivered` or `failed`). Lead events carry `data.lead_id` and the lead under `data.lead`.
  • `app.uninstalled` is delivered only to the app an agency removed (when the agency revokes the installation), with `data.app_id` and `data.installation_id`.
  • Webhook event subscriptions are bounded before storage and unsupported event errors are truncated for portal display.
  • Webhook delivery records reject unsupported event names and bound stored receiver response status/body data for portal review.
  • Webhook endpoint URLs must use public HTTPS hosts without userinfo or fragments; localhost, private network targets, unresolved hosts, and private DNS answers are rejected.
  • Webhook delivery timeouts are bounded and fall back to the safe default when misconfigured.
  • Webhook payloads use the normalized public response data plus request id and agency metadata.
  • Lead storage and all lead domain behavior are owned by UpHex's CRM; the API edge does not emit lead events itself.

Browser origins

Browser-based integrations can call `/v1`, `/oauth`, and `/mcp` endpoints only from configured CORS origins.

  • Set `CORS_ALLOWED_ORIGINS` to a comma-separated origin list.
  • App connection details repeat this requirement so browser integration failures start from the API edge configuration.
  • CORS preflights are accepted only for documented public methods and headers.
  • CORS origins and requested preflight headers must be single-valued and bounded.
  • Allowed browser responses expose request id, rate limit, MCP session id, and idempotency replay headers.
  • Portal session pages are not opened for arbitrary cross-origin browser calls.

Partner integrations

Build a CRM integration (for example an AI appointment setter) that agencies install once and that works across all of their clients: leads, messages, custom channels, booking, and webhooks.

Install flow

A partner is one public app that each agency installs once; the installation covers every client in the agency.

  • UpHex reviews the app first: apps start private, and approval makes them `public` with the approved scopes and redirect URIs as a snapshot (see Publishing your app).
  • Send the agency user to `/oauth/authorize` with `response_type=code`, your `client_id`, an exact registered `redirect_uri`, `scope`, `state`, and a PKCE `S256` `code_challenge`. With Developer Portal sign-in, owners, admins, and developers approve and users with several agencies pick one (`agency_id`); with UpHex agency sign-in (`AGENCY_APP_INSTALLS_ENABLED`), only an agency admin approves, and Deny returns `error=access_denied`.
  • Approval records the agency's installation with the approved scopes and returns a single-use code (10 minutes). Exchange it at `/oauth/token` with `code_verifier`; access tokens last 1 hour and refresh tokens 30 days, rotating on every refresh.
  • Tokens are bound to the installation's agency (`GET /v1/health` returns its `agency_id`), so `X-Agency-Id` is not needed (a different value returns `403`), and never carry more than the installation's granted scopes.
  • Uninstalling revokes every token and unexchanged code of the installation at once: calls return `401` (request log reason `app_installation_revoked`), refresh returns `invalid_grant`, and the app receives `app.uninstalled`.

Scopes per capability

Request only what the integration uses; webhook events are gated by the same scopes.

  • `leads:read`: leads, lookup, notes, tags list, pipelines, channel opt-outs, incremental sync, and `lead.*` events.
  • `leads:write`: create/match, update, tag, note, activity, stage move, opt-out changes.
  • `conversations:read`: conversations, message history, and `message.*` events.
  • `messages:send`: SMS/email sends and custom-channel logging; `messages:send` or `conversations:read` reads messaging readiness.
  • `calendars:read`: calendars, free slots, appointments, and `appointment.*` events. `appointments:write`: book, reschedule, cancel.
  • `webhooks:manage` is reserved: webhook endpoints are managed in the developer portal today.

Identity model

An UpHex client is a partner location or sub-account; a lead is a contact. Every lead, conversation, calendar, and appointment call names its `client_id`.

  • `client_id` must belong to the installation's agency; any other id returns `404` `client_not_found`.
  • Lead ids are stable CRM lead ids and are only valid with their own `client_id`. There is one conversation per lead; webhook `conversation_id` is the lead id.
  • A bookable unit is an event type inside a native UpHex calendar. Appointment ids are `bk_<id>` (native, writable) or `la_<id>` (synced from GHL, Calendly, or Google, read-only).

Lead upsert recipe

Look the contact up, then match-create so a race with another writer can never duplicate it.

  • `GET /v1/leads/lookup?client_id=&email=` (or `phone=`): exact email-then-phone match, `200` or `404`.
  • `POST /v1/leads` with `match: "email_then_phone"` and an `Idempotency-Key`: `201` (`meta.matched: false`) when created, `200` (`meta.matched: true`) when an existing lead matched.
  • `PATCH /v1/leads/{id}` updates `full_name`, `email`, `phone`, `status`, and `metadata` (replaced whole) and can move the pipeline stage.
  • Tags are by name (`POST /v1/leads/{id}/tags`, `DELETE /v1/leads/{id}/tags/{tag}`); notes are authored as your app.

Sending and custom channels

`POST /v1/leads/{id}/messages` requires `Idempotency-Key`. `type: sms` and `type: email` send through UpHex; `type: custom` only logs a message from another channel.

  • SMS needs a provisioned sender number with approved A2P registration; email needs an active sending domain (client, else agency, else the UpHex platform domain). Plan, wallet, opt-outs, and GoHighLevel routing are checked at send time.
  • `GET /v1/clients/{client_id}/messaging_readiness` returns `sms.ready` / `sms.reason` (`no_from_number_provisioned` or `a2p_campaign_not_ready`) and the email sending domain, before you enable a client.
  • Refused sends keep a stable `code`; the full list is under Conversations & messages.
  • Custom messages take `direction`, `channel_label`, optional `external_id` and `occurred_at`. They dedupe on `external_id` (else the key): a repeat returns `200` with `meta.idempotent_replay: true`; a different message with the same id returns `409` `external_id_conflict`.
  • Inbound custom messages fire `contact_replied` workflows and a `message.inbound` webhook, including for messages your app logged.

Calendars, webhooks, and sync

Booking works on native UpHex calendars only in v1; webhooks are the primary change feed and incremental lead sync is the backstop.

  • List calendars, read free slots (up to 62 days), then `POST /v1/appointments` with an `Idempotency-Key`. Rescheduling returns a new appointment with `previous_appointment_id`; cancel with `POST /v1/appointments/{id}/cancel`.
  • Subscribe a portal webhook endpoint to the events you handle, verify `UpHex-Signature`, de-duplicate transport retries on `UpHex-Delivery-Id` and business effects on `data.event_id`.
  • Appointment events carry the API's ids (`bk_` / `la_`) and the integer calendar id.
  • Poll `GET /v1/leads?client_id=&updated_since=` and follow `meta.next_cursor` to catch anything a webhook missed.
  • All of an app's installations share one per-app rate limit (default 120 requests per minute).

GoHighLevel API v2 to UpHex

GoHighLevel UpHex
GET /contacts/{id} GET /v1/leads/{id}?client_id=
GET /contacts/search/duplicate GET /v1/leads/lookup?client_id=&email=|phone=
POST /contacts/search GET /v1/leads?client_id=&search=&tag=&status=
POST /contacts/upsert POST /v1/leads with match: email_then_phone
PUT /contacts/{id} PATCH /v1/leads/{id}
DELETE /contacts/{id} Not supported
POST /contacts/{id}/tags POST /v1/leads/{id}/tags
DELETE /contacts/{id}/tags DELETE /v1/leads/{id}/tags/{tag}
GET|POST /contacts/{id}/notes GET|POST /v1/leads/{id}/notes
/contacts/{id}/tasks Not supported (use /v1/leads/{id}/activities)
GET /opportunities/pipelines GET /v1/pipelines?client_id=
PUT /opportunities/{id} PATCH /v1/leads/{id}/pipeline_stage
GET /conversations/search GET /v1/conversations?client_id=
GET /conversations/{id}/messages GET /v1/leads/{id}/messages
POST /conversations/messages (SMS, Email) POST /v1/leads/{id}/messages type sms|email
POST /conversations/messages/inbound POST /v1/leads/{id}/messages type custom
GET /calendars/ GET /v1/calendars?client_id= (calendar = event type)
GET /calendars/{id}/free-slots GET /v1/calendars/event_types/{event_type_id}/free_slots
POST /calendars/events/appointments POST /v1/appointments
PUT /calendars/events/appointments/{id} PATCH /v1/appointments/{id}
DELETE /calendars/events/{id} POST /v1/appointments/{id}/cancel
GET /calendars/events GET /v1/appointments?client_id=&from=&to=
Webhooks ContactCreate / ContactUpdate lead.created / lead.updated
Webhook ContactTagUpdate lead.tag_added / lead.tag_removed
Webhooks InboundMessage / OutboundMessage message.inbound / message.outbound.status
Webhooks AppointmentCreate / Update / Delete appointment.created / rescheduled / canceled
Webhook AppUninstall app.uninstalled

Support escalation

Send integration issues to developers@uphex.com with the `X-Request-ID`, HTTP method, path, timestamp, app name, agency id, and public error body. Do not send API secrets, OAuth secrets, access tokens, refresh tokens, webhook signing secrets, or provider tokens.

UpHex traces the edge request from `/portal/requests` while backend, CMS, and connector keep their domain data ownership.

Response envelope

Successful downstream responses are normalized before developers see them. Safe pagination and date metadata are preserved.

{
  "data": [],
  "meta": {
    "resource": "leads",
    "page": 1,
    "per_page": 25,
    "total_entries": 80,
    "total_pages": 2
  }
}

Error envelope

API-edge validation and policy failures use field-keyed arrays. Downstream errors are converted into a stable list with code, title, detail, and source.

{
  "errors": {
    "base": ["X-Agency-Id must be a positive integer"]
  }
}

{
  "errors": [
    {
      "code": "downstream_rejected",
      "title": "Downstream request rejected",
      "detail": "client_id is required",
      "source": "backend"
    }
  ]
}

Webhook signatures

Webhook deliveries are signed with HMAC-SHA256 over the timestamp and raw JSON body, keyed with the hex SHA-256 digest of your signing secret.

UpHex-Event: lead.created
UpHex-Delivery-Id: <stable id across retries>
UpHex-Signature-Timestamp: <unix seconds>
UpHex-Signature: v1=<hex(hmac_sha256(hex(sha256(secret)), timestamp + "." + body))>