A conventional REST API covering every partner administration operation — customers, AI Gateway keys, VPS deployments, credits, billing, agents, and more. Backed by the same tool catalog as the Partner MCP Gateway, the same pkt_ API keys, and the same scope model. Use this when you want deterministic calls from scripts, CI pipelines, n8n/Zapier nodes, or any non-AI client.
Live spec URL (regenerated per request): https://mcp.knotie-ai.pro/api/partner-rest/openapi.json — also served at /docs/openapi/partner-api.json on this host.
Base URLs. The partner gateway is https://mcp.knotie-ai.pro — use it for every /api/partner-rest/* and /api/partner-mcp call on this page. Main-app partner routes (/api/partner/…, including customer onboarding) stay on https://knotie-ai.pro or your own white-label domain — do not send those to the gateway host. A development gateway also runs at connecthub.kno2gether.com; use it only if you were explicitly given access.
allowedApps accepts. Derived from the live schema and catalogs, so it tracks the product on its own (below). Scope customers:read.POST /api/partner/customers/onboard (documented in full below). Scope customers:write.businessPhone, autoEmbeddingEnabled, kbTier, showSocialPlanner, showPhoneNumbers, showAiGateway, enableAiMemory, showMcp, showMarketplaceSolutions, showMediaStudio, showWebsiteStudio, showAiAgents, selfServeAgentLimit, showVpsCatalogApps, vpsMarginPercent, enableTeamMembers, maxTeamMembers, lowCreditThreshold, aiCreditGracePeriodSeconds, restrictCustomerCreditPlanVisibility, customerInfraResellerMode, customerAgencyDashboardMode, storageAllocatedMB. It is a pure passthrough — clamping, master-gating and range validation stay in the main app, so the gateway can never drift into a second, weaker policy layer.customer_credits_top_up which moves credits once. monthlyAllocation: 0 turns the monthly grant off; rolloverEnabled decides whether an unspent month carries forward. Scope credits:write.pkt_ key that made the call ([via api-key:<name>]), so automated top-ups, deductions and monthly allocations are traceable to a key rather than to "the partner". One honest exception: the initialAiCredits grant inside customers_onboard is attributed to the partner, because the gateway reaches the main app as a minted partner token. Use customer_credits_top_up when the ledger must name the key.selfServeAgentLimit, the dashboard-mode tri-states, the four credit keys, and an experienceType binding validated against your own experiences. A plan passed as planId to customers_onboard therefore configures as much as the dashboard's customer editor can.No hand-maintained spec to wait on: the OpenAPI document is generated from the live tool registry on every request, so these operations are already in it — schemas, scopes and all.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /api/partner-rest/health | none | Liveness check. |
| GET | /api/partner-rest/openapi.json | none / optional | Full OpenAPI 3.1 document. With a pkt_ bearer, the spec is scope-filtered to the operations that key can invoke. |
| GET | /api/partner-rest/tools | Bearer | Scope-filtered tool listing with input schemas — useful for dynamic form generation. |
| POST | /api/partner-rest/tools/:toolName | Bearer | Execute one tool. Body is the JSON input. Response is the tool result. |
POST /api/partner/customers/onboard is a main-app endpoint (not a gateway tool path) — it lives on knotie-ai.pro or on your own white-label domain, and you can call it directly with either credential type. The gateway exposes exactly the same contract as the customers_onboard tool.
| Method | Path | Auth | Scope |
|---|---|---|---|
| POST | /api/partner/customers/onboard | X-API-Key: pkt_… or Authorization: Bearer <partner JWT> | customers:write (via the gateway tool) |
| POST | /api/partner-rest/tools/customers_onboard | Authorization: Bearer pkt_… | customers:write |
When both are present the X-API-Key header wins — an explicit key header is an unambiguous statement of intent, so it is never silently ignored. A customer key (ckt_) is rejected even though it carries a partner id: it is a customer's credential and must not be able to mint that partner's customers. The endpoint is behind the Redis-backed partner rate limiter and returns 429 when you exceed it.
planId features — the primary upsell leverfeatures overrides — secondary, per-customerStep 4 runs even when you send no overrides at all: a stored plan row may predate a master switch being turned off, so the clamp is what makes the grant safe — not the input validation. The five master-gated keys are showMediaStudio, showWebsiteStudio, showAiAgents, enableAiMemory and showAiGateway. Requesting one while its master switch is off is not an error — the key is written false and the reason lands in warnings[]. Read that array.
| Field | Type | Notes |
|---|---|---|
| string — required | Lower-cased and trimmed server-side. Unique per partner; this is the idempotency key. | |
| firstName | string ≤ 200 | Defaults to the local part of the email. |
| lastName | string ≤ 200 | — |
| companyName | string ≤ 300 | Defaults to "firstName lastName". |
| planId | string | One of your subscription plans. Applied before overrides and before the clamps. |
| experienceType | string 1–64 | Validated against your own experiences. An unknown value is a warning, not an error — the customer is still created, just without the binding. |
| features | object | Per-customer overrides, validated by the same schema plans use. See the key list below. |
| initialAiCredits | integer 0–1,000,000 | Granted to the new customer's credit balance. A hard leg — if it fails the whole customer is rolled back. |
| sendWelcomeEmail | boolean (default true) | The welcome email is the only delivery channel for the generated password. Set false only if you will issue credentials separately. |
| idempotencyKey | string ≤ 200 | Optional caller-supplied de-duplication key. De-duplication itself is on (partner, email). |
| password | rejected | There is no password field and there never will be. Sending the key is a hard 400, not a silent strip. A secure password is generated server-side and delivered only in the branded welcome email. |
features keys are honouredAnything outside these lists is ignored rather than written — the body must never be able to name an arbitrary column.
enableAdvancedAnalytics, enableDetailedCallAnalysis, enableActionPointAnalysis, showIntegration, showKnowledgeBase, showDocsAndMedia, showSocialPlanner, showPhoneNumbers, showCampaigns, showScheduleMeeting, showPricingInformation, showMarketplaceSolutions, showMcp, showServerTerminal, enableVpsPurchase, vpsMarginPercent, showVpsCatalogApps, autoEmbeddingEnabled, enableApiAccess, showApiKeys, enableTeamMembers, maxTeamMembers, storageAllocatedMB, selfServeAgentLimit, customerInfraResellerMode, customerAgencyDashboardModeshowMediaStudio, showWebsiteStudio, showAiAgents, enableAiMemory, showAiGatewayaiCreditsEnabled, aiCreditPricePerMinute, aiCreditGracePeriodSeconds, lowCreditThreshold, lowCreditNotificationsEnabled, restrictCustomerCreditPlanVisibility, visibleCustomerCreditPlanIds, kbProcessingEnabled, kbTierselfServeAgentLimit is clamped to 0–20.enableApiAccess: false also forces showApiKeys: false.kbTier: "premium" needs the admin-granted premium ingestion entitlement; without it the customer is created on the default tier and you get a warning.visibleCustomerCreditPlanIds is re-validated against your own credit plans — ids you do not own are dropped and listed in warnings[] rather than failing the call.customers_update, enableAiMemory here can never flip your partner-wide switch. An API call must not change partner-level state.curl -X POST https://knotie-ai.pro/api/partner/customers/onboard \
-H 'X-API-Key: pkt_YOUR_KEY' \
-H 'Content-Type: application/json' \
-d '{
"email": "[email protected]",
"firstName": "Alice",
"lastName": "Smith",
"companyName": "Acme Corp",
"planId": "plan_abc123",
"experienceType": "MEDIA_STUDIO",
"features": {
"showKnowledgeBase": true,
"showMediaStudio": true,
"selfServeAgentLimit": 3
},
"initialAiCredits": 250,
"sendWelcomeEmail": true
}'curl -X POST https://mcp.knotie-ai.pro/api/partner-rest/tools/customers_onboard \
-H 'Authorization: Bearer pkt_YOUR_KEY' \
-H 'Content-Type: application/json' \
-d '{ "email": "[email protected]", "companyName": "Acme Corp",
"planId": "plan_abc123", "initialAiCredits": 250 }'{
"customerId": "cus_abc123",
"userOnboardingId": "onb_abc123",
"created": true,
"existing": false,
"appliedPlanId": "plan_abc123",
"appliedFeatures": [
"plan:plan_abc123",
"experienceType:MEDIA_STUDIO",
"showKnowledgeBase",
"selfServeAgentLimit",
"showMediaStudio",
"initialAiCredits"
],
"warnings": []
}appliedFeatures lists what actually landed — it is the honest record, not an echo of your request. Through the gateway the same result is wrapped with a leading outcome (CREATED / EXISTING_UNCHANGED), an attention line and warningCount, so an AI agent cannot scroll past a clamped grant.
{
"customerId": "cus_existing",
"userOnboardingId": "onb_existing",
"created": false,
"existing": true,
"appliedFeatures": [],
"warnings": [
"A customer with this email already exists for this partner — returned unchanged. Nothing was mutated (planId, features, and initialAiCredits were ignored)."
]
}This is the contract, not a bug: re-applying a plan or overrides on a repeat call would let a retry silently rewrite a live customer's entitlements, which is exactly what idempotency exists to prevent. To change an existing customer use customers_update / customer_credits_top_up / customer_credits_set_monthly.
Money and toggles are hard failures. If the plan application, the override write or the credit grant fails, the customer is rolled back by a compensating delete and the call returns 500 — you never get a half-configured, billable account. The welcome email is the only leg allowed to degrade: an undelivered email becomes a warnings[] entry and the account still exists.
| HTTP | When |
|---|---|
| 201 | Customer created. |
| 200 | Customer already existed — returned unchanged. |
| 400 | A password key was present, the JSON body was unparseable, or the body failed validation (field errors are returned in details). |
| 401 | No partner bearer token and no partner API key — or a key that is not a partner key. |
| 404 | The authenticated partner no longer exists. |
| 429 | Partner rate limit exceeded. Retry shortly. |
| 500 | A hard leg failed; the customer was rolled back and was not created. Safe to retry. |
Rather than hard-coding the field list above into your integration, ask for it. GET /api/partner/customers/capabilities returns the complete writable surface for your account — and it is derived at request time from the same validation schema and catalogs the product itself runs on, so it never falls behind a release. Same auth as the onboarding endpoint (partner bearer token or pkt_ key); read-only; mutates nothing.
features — every key you may send inside features, with its type (boolean, number, string, array, or tri-state for the null-means-inherit toggles), its declared bounds or enum values, and a human label and description where we have one.masterGate — on each master-gated key: which partner switch backs it and whether yours is currently on. This is how you find out a grant would be clamped before you send it, instead of reading it back out of warnings[].experiences — the exact experienceType values that will validate for your account. Anything else is dropped with a warning.apps — the ids and names accepted in features.allowedApps, with category and stability.plans — your planId values, so you never have to look one up by hand.curl https://knotie-ai.pro/api/partner/customers/capabilities \
-H 'X-API-Key: pkt_YOUR_KEY'{
"counts": { "features": 44, "experiences": 2, "apps": 39, "plans": 3 },
"features": [
{
"key": "selfServeAgentLimit",
"type": "number", "integer": true, "min": 0, "max": 20,
"optional": true, "nullable": false,
"label": null, "description": null, "category": null,
"masterGate": null
},
{
"key": "showMediaStudio",
"type": "boolean", "optional": true, "nullable": false,
"label": "Media Studio",
"description": "Customer can generate images & video in the branded studio …",
"category": "infrastructure",
"masterGate": {
"partnerField": "mediaStudioEnabled",
"enabled": false,
"label": "Media Studio",
"note": "Your Media Studio master switch is OFF — requesting this will be
clamped to false and reported in warnings[]."
}
},
{ "key": "kbTier", "type": "string", "enum": ["essential","moderate","premium"] },
{ "key": "showVpsCatalogApps", "type": "tri-state", "nullable": true }
],
"experiences": [
{ "experienceType": "AI_RECEPTIONIST", "name": "Front Desk", "isDefault": true, "enabled": true }
],
"apps": [ { "id": "gmail", "name": "gmail", "displayName": "Gmail", "category": "free" } ],
"plans": [ { "id": "plan_abc123", "name": "Starter", "isActive": true } ]
}curl -X POST https://mcp.knotie-ai.pro/api/partner-rest/tools/customers_capabilities \
-H 'Authorization: Bearer pkt_YOUR_KEY' \
-H 'Content-Type: application/json' -d '{}'Re-read it rather than caching it. New toggles, new experiences and new apps appear here automatically — that is the point of the endpoint, and a cached copy throws it away.
Three calls turn any landing page into a storefront on your own Stripe account: capabilities (what can I sell?) → customers/onboard (register the buyer, optional) → payment-links (get a checkout URL). The buyer pays you — the Checkout session is created on your connected Stripe account — and our webhook is what turns that payment into a provisioned, entitled customer.
The webhook is the truth, not the redirect. A buyer closing the tab, or hand-editing your success URL, changes nothing: entitlements are granted only when Stripe tells us (signature-verified) that the session was paid. Confirm fulfilment on your thank-you page with GET /api/partner/payment-links/{intentId} (or ?stripeSessionId=), never from the URL the browser landed on.
422) until your connected account exists and has charges enabled.PUT /api/partner/settings/redirect-domains. Success and cancel URLs must be https on an approved domain or a subdomain of one. Stripe accepts any URL; this list is our guard, and it is what stops a leaked key from redirecting your paying buyers somewhere else. An empty list means no links can be minted.payments:write. A customers/CRM key cannot mint money links. Grant the scope on the key in Settings → API Keys.curl -X PUT https://knotie-ai.pro/api/partner/settings/redirect-domains \
-H 'X-API-Key: pkt_YOUR_KEY' -H 'Content-Type: application/json' \
-d '{ "domains": ["their-agency.com"] }'curl -X POST https://knotie-ai.pro/api/partner/payment-links \
-H 'X-API-Key: pkt_YOUR_KEY' -H 'Content-Type: application/json' \
-d '{
"email": "[email protected]",
"amount": 49900,
"currency": "usd",
"interval": "one_time",
"experienceType": "MEDIA_STUDIO",
"features": { "showMediaStudio": true, "showKnowledgeBase": true },
"successUrl": "https://their-agency.com/thanks?session={CHECKOUT_SESSION_ID}",
"cancelUrl": "https://their-agency.com/pricing",
"description": "Creator Pack - launch offer"
}'{
"intentId": "b0c1…",
"url": "https://checkout.stripe.com/c/pay/cs_test_…",
"stripeSessionId": "cs_test_…",
"status": "created",
"amount": 49900,
"currency": "usd",
"interval": "one_time",
"expiresAt": "2026-08-16T12:00:00.000Z",
"statusUrl": "/api/partner/payment-links/b0c1…"
}| Field | Notes |
|---|---|
| email / customerId | Who is buying. One is required. customerId must be a customer on your account; otherwise the buyer is resolved or created at grant time, idempotently by (partner, email). |
| amount | Integer minor units (cents). Bounded — 100 to 5,000,000 by default. Fractional amounts are refused, never rounded. |
| currency | usd, gbp, eur, aud, cad, sgd, inr. |
| interval | one_time, month or year. Subscriptions use Stripe subscription mode. |
| experienceType | Must be one of YOUR experiences (see capabilities). Anything else is a 400 — a buyer is about to pay for it. |
| features | Same vocabulary as onboarding. Master-gated keys are clamped at GRANT time and the clamp is reported in the intent’s warnings[]. |
| successUrl / cancelUrl | https, on an approved domain. {CHECKOUT_SESSION_ID} is passed through untouched so Stripe can substitute it. |
| description | Buyer-visible product name. Your copy and your business name only — nothing on the Stripe page names the platform. |
| expiresAt | Optional ISO-8601. Defaults to 24h. The link is single-use: once paid, it can never grant twice. |
curl 'https://knotie-ai.pro/api/partner/payment-links?stripeSessionId=cs_test_…' \
-H 'X-API-Key: pkt_YOUR_KEY'
# 200 OK
# { "intentId": "b0c1…", "status": "completed", "paid": true,
# "customerId": "…", "userOnboardingId": "…", "warnings": [] }paid flips only when the signature-verified webhook has applied the grant. Until then the status is created. If the buyer paid but a feature you asked for is missing, look at warnings[] — a master switch that is off on your account clamps the grant rather than selling something your account cannot hand out.
Rate limits: the standard partner limiter, plus a per-partner cap of 200 minted links per rolling 24 hours (429 when reached; existing links keep working).
REST responses auto-adapt to the caller's Accept header so scripts, pipelines, and curl callers get plain JSON while MCP-aware clients receive the spec-compliant wrapped envelope.
When Accept does not include text/event-stream, the gateway parses the MCP envelope and returns the main-app JSON directly. An X-Partner-Mcp-Unwrapped: true response header confirms the auto-unwrap happened.
// Response headers include:
// X-Partner-Mcp-Unwrapped: true
{
"success": true,
"data": [ /* customers... */ ],
"pagination": { "totalCount": 42 }
}If the upstream tool reported a domain error, the auto-unwrap flow surfaces it as HTTP 502 with a standard { error: { code, message, data } } body — so your client can use try/catch and status-code checks like any normal REST API.
When Accept includes text/event-stream(MCP clients always send this), OR when you explicitly add ?wrapped=1, the raw MCP tool-result envelope is returned so the shape matches POST /api/partner-mcp exactly.
{
"content": [
{ "type": "text", "text": "{ \"success\": true, \"data\": [/* ... */] }" }
]
}The text field holds the main-app JSON verbatim. AI agents render it as a tool observation; REST SDKs would have to JSON.parse() it — which is why the unwrapped form above is usually easier for deterministic clients.
curl https://mcp.knotie-ai.pro/api/partner-rest/openapi.json | jq '.paths | keys | length'
# → 137 (135 tools + 2 discovery endpoints: /tools and /openapi.json)
# The spec is rebuilt from the live tool registry on every request, so newly
# shipped tools are already in it. Confirm the latest ones are there:
curl -s https://mcp.knotie-ai.pro/api/partner-rest/openapi.json \
| jq '.paths | keys[] | select(test("customers_onboard|customer_credits_set_monthly|customers_update"))'curl https://mcp.knotie-ai.pro/api/partner-rest/tools \
-H 'Authorization: Bearer pkt_YOUR_KEY' | jq '.tools[] | .name'curl -X POST https://mcp.knotie-ai.pro/api/partner-rest/tools/customers_onboard \
-H 'Authorization: Bearer pkt_YOUR_KEY' \
-H 'Content-Type: application/json' \
-d '{
"email": "[email protected]",
"firstName": "Alice",
"lastName": "Smith",
"companyName": "Acme Corp",
"planId": "plan_abc123",
"initialAiCredits": 250
}'
# The legacy customers_create (6 fields, and you must supply a plaintext
# password) still exists for older automation. Prefer customers_onboard —
# see "One-call customer onboarding" above.curl -X POST https://mcp.knotie-ai.pro/api/partner-rest/tools/customer_credits_set_monthly \
-H 'Authorization: Bearer pkt_YOUR_KEY' \
-H 'Content-Type: application/json' \
-d '{
"customerId": "cus_abc123",
"monthlyAllocation": 500,
"rolloverEnabled": false,
"reason": "Growth plan monthly allocation"
}'
# monthlyAllocation: 0 turns the recurring grant off.
# The ledger row is stamped "[via api-key:<your key name>]" automatically —
# do not append the key name to "reason" yourself.# Purchase VPS (returns checkout URL)
curl -X POST https://mcp.knotie-ai.pro/api/partner-rest/tools/vps_checkout \
-H 'Authorization: Bearer pkt_YOUR_KEY' \
-H 'Content-Type: application/json' \
-d '{ "planId": "vps-plan-2", "region": "eu-fra", "customerId": "cus_abc" }'
# Then deploy apps onto the provisioned instance
curl -X POST https://mcp.knotie-ai.pro/api/partner-rest/tools/vps_instance_reinstall \
-H 'Authorization: Bearer pkt_YOUR_KEY' \
-H 'Content-Type: application/json' \
-d '{ "instanceId": "inst_xyz", "applicationIds": ["n8n","supabase","redis"] }'Feed the OpenAPI document into any standard generator to get a typed client in your language of choice.
# 1. Download the spec
curl https://mcp.knotie-ai.pro/api/partner-rest/openapi.json > openapi.json
# 2. Generate types
npx openapi-typescript openapi.json -o ./knotie-partner-api.d.ts
# 3. Use
import createClient from 'openapi-fetch';
import type { paths } from './knotie-partner-api';
const client = createClient<paths>({
baseUrl: 'https://mcp.knotie-ai.pro',
headers: { Authorization: 'Bearer pkt_YOUR_KEY' },
});
const { data } = await client.POST(
'/api/partner-rest/tools/customers_create',
{ body: { email: '[email protected]', firstName: 'Alice', lastName: 'Smith',
companyName: 'Acme', password: 'hunter2!' } },
);curl https://mcp.knotie-ai.pro/api/partner-rest/openapi.json > openapi.json
pip install openapi-python-client
openapi-python-client generate --path openapi.json
# Then use the generated client:
from knotie_partner_api import Client
from knotie_partner_api.api.customers import customers_create
client = Client(base_url='https://mcp.knotie-ai.pro',
headers={'Authorization': 'Bearer pkt_YOUR_KEY'})
result = customers_create.sync(client=client, json_body={...})Each tool declares a required scope like customers:read or vps:write. Scopes are set on each pkt_ key in the partner dashboard.
* — unrestricted (default for existing keys)domain:* — all read + write in a domain (e.g. vps:*)domain:read / domain:write — specific accessA 403 with data.type: "insufficient_scope" tells you which scope is required vs. which scopes the key holds. The OpenAPI spec's x-partner-mcp-scope extension advertises each operation's required scope.
| HTTP | data.type | Meaning |
|---|---|---|
| 200 (isError:true) | — | Tool ran but the main app returned a domain error (e.g. 404 on upstream lookup). Body is still a tool-result envelope. |
| 401 | missing_credentials / invalid_credentials / expired / revoked / mcp_disabled_on_key / ip_not_allowed | WWW-Authenticate header points to the PRM document. |
| 403 | insufficient_scope / partner_not_approved / subscription_inactive / origin_rejected | Authenticated, but not permitted. |
| 404 | tool_not_found | Unknown tool name in path. |
| 429 | daily_limit_exceeded / monthly_limit_exceeded | Retry-After header set with seconds until reset. |
| 503 | feature_disabled | REST facade disabled on this instance. |
The REST facade exposes no partner-auth operations(login, password change, MFA setup, session issuance) and no customer impersonation. A malfunctioning agent cannot take over the partner account or any customer account via this API.
customers_reset_password, customers_enable_portal) send the new credentials to the customer's email — they never appear in the API response body.⚠️ SENSITIVE in their summary field and gated by the domain scopes (vps:*, customer-api-keys:write, ai-gateway:write, embed:write). Omit those scopes from your pkt_ key if the agent shouldn't be able to emit secrets.partner_mcp_invocations.Full model: Partner MCP / Security model.
Every auth failure returns a structured data.type that tells you exactly what to fix. Common ones:
| data.type | What it means | How to fix |
|---|---|---|
| missing_credentials | No Authorization: Bearer or X-API-Key header. | Add -H 'Authorization: Bearer pkt_...'. We do NOT accept Basic. |
| invalid_credentials | Key wasn't found OR decryption failed. Three real sub-cases: |
|
| expired / revoked | Key lifecycle state. | Create a new key in the dashboard. |
| mcp_disabled_on_key | Key is valid but MCP access is toggled off. | Open the key's scope editor in Settings → Partner API Keys and flip "Enable MCP" on. |
| ip_not_allowed | Client IP is not in the key's allowedIps list. | Add the IP in the key's settings or remove the allowlist. |
| insufficient_scope | Authenticated, but the key doesn't hold the required scope. | Response body includes required_scope and granted_scopes. Update the key in the scope editor. |
| daily_limit_exceeded | Key hit its daily request budget. | Honor the Retry-After header, or raise the limit on the key. |
Partner keys are encrypted at rest by the main app's ENCRYPTION_KEY and decrypted by Connect Hub. The delegated service JWT on every tool call is signed with the main app's JWT_SECRET. On Connect Hub, those same values live under the unambiguous names MAIN_APP_ENCRYPTION_KEY and MAIN_APP_JWT_SECRET — Connect Hub has its OWN ENCRYPTION_KEY / JWT_SECRET for local purposes which are typically different values.
Verify in each service's runtime:
echo -n "$ENCRYPTION_KEY" | sha256sum
echo -n "$JWT_SECRET" | sha256sumecho -n "$MAIN_APP_ENCRYPTION_KEY" | sha256sum
echo -n "$MAIN_APP_JWT_SECRET" | sha256sumCorresponding hashes MUST match byte-for-byte. A mismatch on encryption → invalid_credentials. A mismatch on JWT → the main app rejects the delegated call with its own 401 (tool runs return isError: true).
On Connect Hub, our auth code emits an error-level log line when decryption fails on every candidate — grep for "MAIN_APP_ENCRYPTION_KEY on Connect Hub does not match".
Both paths share the same pkt_ keys, scope model, audit trail, rate limits, and business-logic delegation. Pick the interface that fits the client.