# Upcoming Features & Roadmap Source: https://docs.knoxcall.com/about/upcoming-features See what's coming to KnoxCall: Workflows, VPN tunnels, GraphQL support, advanced monitoring, and more. Vote on features and request new capabilities. # Upcoming Features & Roadmap Exciting new features are coming to KnoxCall! Here's what we're building next. ## Recently Launched ### 🔄 Workflows **Visual workflow builder** for complex API orchestration is now available! **Features:** * Drag-and-drop workflow designer * Multi-step API sequences * Conditional logic and branching * Error handling with try/catch * Loop over arrays * Custom JavaScript code blocks * Delay/timing nodes * Real-time execution monitoring * Version history **Example workflow:** ```text theme={"dark"} 1. Receive webhook from Stripe ↓ 2. Validate payment signature ↓ 3. If successful: → Update database (API call) → Send confirmation email (API call) → Trigger fulfillment (API call) ↓ 4. If failed: → Log error → Send alert to Slack ``` **Learn more:** [Workflows Documentation](/workflows/workflows-overview) *** ### 🔔 Webhooks **Real-time notifications** when API requests pass through your routes: **Features:** * 8 event types (success, error, timeout, etc.) * Multiple authentication methods (Bearer, Basic, HMAC) * Automatic retry with exponential backoff * Route filtering * Payload customization * Execution logging * Signature verification **Learn more:** [Webhooks Documentation](/webhooks/webhooks-overview) *** ### 🔐 VPN & Data Plane **Secure tunnels** to on-premise backends are now available for Enterprise customers: **Features:** * WireGuard-based VPN tunnels * Connect KnoxCall to private networks * No public IP required for backend * Encrypted traffic * Multi-region data plane nodes * Health monitoring **Contact sales for Enterprise features.** *** ## Coming Soon ### 📊 GraphQL Support (Q2 2025) **Proxy GraphQL endpoints** with same KnoxCall features: **What it is:** * GraphQL query proxying * Secret injection in GraphQL queries * Query complexity analysis * Schema introspection caching * Persisted queries **Example:** ```graphql theme={"dark"} query GetUser($id: ID!) { user(id: $id) { name email # apiKey injected from secret automatically } } ``` **Use cases:** * GraphQL API proxying * Multi-schema stitching * GraphQL rate limiting * Query cost analysis **Status:** Planning phase, Q2 2025 *** ### 🌍 Multi-Region Routing (Q2 2025) **Automatically route to nearest backend** based on client location: **What it is:** * Geographic load balancing * Latency-based routing * Failover between regions * Health checks per region **Example configuration:** ```text theme={"dark"} Route: api-endpoint Regions: - US-East (Virginia) → api-us.example.com - EU-West (Ireland) → api-eu.example.com - Asia (Singapore) → api-asia.example.com Client in London → Routes to EU-West Client in Tokyo → Routes to Asia Client in NYC → Routes to US-East ``` **Use cases:** * Global applications * Latency optimization * Regional compliance (GDPR) * Disaster recovery **Status:** Planning phase, Q2 2025 *** ### 📈 Advanced Analytics (Q2 2025) **Enhanced analytics and visualizations:** **Features:** * Custom dashboards * Advanced filtering * Cohort analysis * Funnel visualization * A/B testing metrics * Cost tracking (API call costs) **Example metrics:** * Request success rate by client * Average latency by endpoint * Error rate trends * Geographic distribution * Peak traffic patterns * Secret usage frequency **Status:** Planning phase, Q2 2025 *** ### 🔔 Alert Improvements (Q2 2025) **Enhanced alerting capabilities:** **Features:** * Custom alert conditions (complex logic) * Alert escalation policies * PagerDuty integration * Opsgenie integration * Microsoft Teams integration * Alert suppression rules * Maintenance windows **Example escalation:** ```text theme={"dark"} 1. Error rate > 5% → Email to team Wait 10 minutes 2. Still failing → SMS to on-call engineer Wait 5 minutes 3. Still failing → Page engineering manager ``` **Status:** Planning phase, Q2 2025 *** ### 🔌 Integrations Marketplace (Q3 2025) **Pre-built integrations** for popular services: **Planned integrations:** * Datadog (metrics export) * New Relic (monitoring) * Sentry (error tracking) * Auth0 (authentication) * Okta (identity management) * Terraform (infrastructure as code) * CloudFormation (AWS) * Kubernetes operators **Use cases:** * Export metrics to existing tools * Integrate with existing workflows * Infrastructure automation **Status:** Planning phase, Q3 2025 *** ### 🤖 AI-Powered Features (Q3 2025) **AI enhancements beyond route setup:** **Planned features:** * Anomaly detection (unusual traffic patterns) * Smart alert tuning (reduce false positives) * Automatic error categorization * Predictive scaling recommendations * Configuration suggestions * Security threat detection **Example:** ```text theme={"dark"} AI Notice: Detected unusual traffic spike from EU region. Suggestion: Add rate limiting to protect backend. [Apply Suggestion] ``` **Status:** Research phase, Q3 2025 *** ### 🔍 Request Replay & Debugging (Q3 2025) **Replay failed requests** for debugging: **Features:** * One-click request replay * Modify request before replay * Compare original vs replay response * Step-through request flow * Inspect secret injection **Use cases:** * Debug intermittent failures * Test configuration changes * Reproduce customer issues * Training and demos **Status:** Planning phase, Q3 2025 *** ## Longer-Term Vision ### Load Testing Integration * Integrated load testing tool * Simulate traffic patterns * Identify bottlenecks * Capacity planning ### API Mocking * Mock backend responses * Development without backend * Contract testing * Sandbox environments ### Rate Limit Sharing * Share rate limits across routes * Tenant-wide rate limiting * Custom quota systems ### Custom Plugins * Write custom proxy logic * JavaScript/TypeScript support * Deploy as serverless functions ### Compliance Reporting * Automated compliance reports * SOC 2 evidence collection * GDPR data mapping * HIPAA audit trails ## Feature Requests **Want a specific feature?** ### Vote on Roadmap **Visit our public roadmap:** * roadmap.knoxcall.com * See all planned features * Upvote features you need * Comment with use cases **Most upvoted features get prioritized!** ### Submit New Request **Have a unique need?** 1. Open chat widget (admin UI) 2. Describe your use case 3. Explain why you need it 4. We'll add to consideration list **Or email:** [features@knoxcall.com](mailto:features@knoxcall.com) ### Enterprise Customers **Custom feature development:** * Enterprise plan includes priority feature requests * We can build custom features for your use case * Faster implementation timeline **Contact:** [sales@knoxcall.com](mailto:sales@knoxcall.com) ## Founding Testers The founding-testers beta is open: 100 places on Pro, free until general availability. What you get, what we ask and how to join are on [knoxcall.com/beta](https://knoxcall.com/beta). ## Changelog **Stay updated on releases:** * **Changelog:** changelog.knoxcall.com * **Release Notes:** Emailed to all users * **Blog:** blog.knoxcall.com * **Twitter:** @knoxcall ## Feedback & Input **We build what you need!** **Ways to influence roadmap:** 1. Vote on roadmap.knoxcall.com 2. Submit feature requests 3. Chat with support about pain points 4. Join the [founding-testers beta](https://knoxcall.com/beta) 5. Participate in user interviews **User-driven development** means we focus on real problems. ## Timeline **Release schedule:** ```text theme={"dark"} ✅ Released: ├─ Workflows (Visual Builder) ├─ Webhooks (Real-time Notifications) ├─ VPN & Data Plane (Enterprise) └─ Enhanced Alerts System Q2 2025 (Apr-Jun): ├─ GraphQL Support ├─ Multi-Region Routing ├─ Advanced Analytics └─ Alert Improvements Q3 2025 (Jul-Sep): ├─ Integrations Marketplace ├─ AI-Powered Features ├─ Request Replay └─ Custom Plugins (Beta) Q4 2025 (Oct-Dec): ├─ Load Testing ├─ API Mocking └─ Compliance Reporting ``` **Note:** Timeline subject to change based on priorities and feedback. ## Stay in the Loop **Get notified about new features:** ### Email Newsletter * Monthly product updates * New feature announcements * Tips and best practices * Sign up: newsletter.knoxcall.com ### Product Blog * In-depth feature guides * Use case studies * Engineering deep-dives * blog.knoxcall.com ### Social Media * Twitter: @knoxcall * LinkedIn: linkedin.com/company/knoxcall * GitHub: github.com/knoxcall ### Release Webinars * Live demos of new features * Q\&A with product team * Best practices * Announced via email ## Questions? **About upcoming features:** * Chat with support (chat widget) * Email: [features@knoxcall.com](mailto:features@knoxcall.com) * Schedule call: calendly.com/knoxcall **We love hearing from users!** Your feedback shapes the product. *** ## Related Resources Vote on features Recent releases Submit ideas Join the beta *** * **Level**: informational * **Last Updated**: January 2025 `roadmap`, `features`, `coming-soon`, `beta`, `future` # Subscription Plans & Billing Source: https://docs.knoxcall.com/account/subscription-plans Understand KnoxCall pricing tiers (Starter, Pro, Enterprise), feature differences, usage limits, and how to upgrade or manage your subscription. # Subscription Plans & Billing Choose the right KnoxCall plan for your organization with transparent pricing and flexible upgrades. ## Plans Overview ### Free Plan - **\$0/month** **Perfect for:** Trying out KnoxCall **Includes:** * ✅ 1 route, 1 client, 1 secret * ✅ 100 API calls/month * ✅ Basic analytics (7 days retention) * ✅ API logs (7 days retention) * ✅ Community support * ✅ 1 environment * ✅ Rate limiting * ✅ Request signing * ✅ Multi-format webhook signing **Limitations:** * ❌ No alerts * ❌ No OAuth2 secrets ### Starter Plan - **\$19/month** **Perfect for:** Individuals and small projects **Includes:** * ✅ 2 routes, 2 clients, 2 secrets * ✅ 10,000 API calls/month * ✅ Basic analytics (7 days retention) * ✅ Community support * ✅ Ephemeral proxy (100k ops/month) * ✅ Rate limiting, request signing & multi-format webhook signing **Limitations:** * ❌ No alerts * ❌ No Real-Time Geo ### Pro Plan - **\$99/month** **Perfect for:** Growing teams, production workloads **Everything in Starter, plus:** * ✅ Extended analytics (30 days retention) * ✅ Extended API logs (30 days retention) * ✅ **Email alerts** * ✅ **Real-Time Geographic tracking** * ✅ Email support * ✅ Audit logs (30 days) * ✅ Advanced filtering * ✅ **mTLS** **Limitations:** * ❌ 30-day retention (not 90+) * ❌ SMS/Slack alert channels (Enterprise) * ❌ No custom SLAs * ❌ No dedicated support **Ideal for:** * Small to medium businesses * Production applications * Teams (up to 5 members) * Multiple clients ### Enterprise Plan - **Custom Pricing** **Perfect for:** Large organizations, compliance requirements **Everything in Pro, plus:** * ✅ Extended retention (90 days) * ✅ Audit logs (90 days retention) * ✅ **Custom integrations** * ✅ **Dedicated support channel** * ✅ **Contractual SLA** — the availability commitment is negotiated in your agreement * ✅ **Onboarding assistance** * ✅ **Priority feature requests** * ✅ **Custom contracts** * ✅ SSO (SAML 2.0) * ✅ Advanced security features * ✅ Volume pricing **Ideal for:** * Enterprises * Compliance-heavy industries (finance, healthcare) * High-volume applications * Custom requirements **Contact sales:** [sales@knoxcall.com](mailto:sales@knoxcall.com) ## Feature Comparison | Feature | Free | Starter | Pro | Enterprise | | - | - | - | - | - | | **Price** | \$0/mo | \$19/mo | \$99/mo | Custom | | **Routes** | 1 | 2 | 25 | Unlimited | | **Clients** | 1 | 2 | 25 | Unlimited | | **Secrets** | 1 | 2 | 25 | Unlimited | | **Team Members** | 1 | 2 | 5 | Unlimited | | **API Calls/month** | 100 | 10,000 | 1,000,000 | Unlimited | | **Analytics Retention** | 7 days | 7 days | 30 days | 90 days+ | | **API Log Retention** | 7 days | 7 days | 30 days | 90 days+ | | **Audit Log Retention** | 7 days | 7 days | 30 days | 90 days | | **Real-Time Geo** | ❌ | ❌ | ✅ | ✅ | | **Email Alerts** | ❌ | ❌ | ✅ | ✅ | | **SMS Alerts** | ❌ | ❌ | ❌ | ✅ | | **Slack Alerts** | ❌ | ❌ | ❌ | ✅ | | **Rate Limiting** | ✅ | ✅ | ✅ | ✅ | | **Request Signing** | ✅ | ✅ | ✅ | ✅ | | **Multi-Format Webhook Signing** | ✅ | ✅ | ✅ | ✅ | | **AI Gateway Agents** | 1 | 1 | 10 | Unlimited | | **Workflows** | 1 | 3 | 25 | Unlimited | | **Inbound Webhooks** | 1 | 5 | 25 | Unlimited | | **mTLS** | ❌ | ❌ | ✅ | ✅ | | **Support** | Community | Community | Email | Dedicated | | **SLA** | None | None | Best effort | Contractual (negotiated) | | **SSO** | ❌ | ❌ | ❌ | ✅ | | **Custom Contracts** | ❌ | ❌ | ❌ | ✅ | ## Usage Limits ### Monthly API Call Caps Each plan includes a monthly API call allowance, enforced per billing month: * **Free:** 100 calls/month * **Starter:** 10,000 calls/month * **Pro:** 1,000,000 calls/month * **Enterprise:** Unlimited **When you approach your cap:** * Usage is visible in Settings → Billing * Upgrade at any time to raise the cap — changes take effect immediately **Enterprise:** * No caps on request volume * Auto-scaling backend * Contractual SLA, negotiated in your agreement ## Upgrading Your Plan ### From Starter to Pro **Steps:** 1. Navigate to Settings → Billing 2. Click **Upgrade to Pro** 3. Enter payment details 4. Confirm subscription **Immediate effects:** * Log retention extended to 30 days * Email alerts enabled * Real-Time Geo unlocked * OAuth2 secrets, mTLS, and format-preserving tokens unlocked **Billing:** * Charged $99/month (or $990/year on annual billing) immediately * Recurring on the same date ### From Pro to Enterprise **Steps:** 1. Navigate to Settings → Billing 2. Click **Contact Sales** 3. Schedule call with sales team 4. Custom pricing discussed 5. Contract signed 6. Account upgraded **Timeline:** * Initial call: 1-2 days * Contract: 1-2 weeks * Implementation: Immediate after signing ## Downgrading Your Plan ### From Pro to Starter **Steps:** 1. Navigate to Settings → Billing 2. Click **Downgrade to Starter** 3. Confirm (prompted about feature loss) **What happens:** * Billed through end of current period * Features remain active until period ends * Then downgrade takes effect **Data retention:** * Logs older than 7 days deleted * Analytics older than 7 days deleted * Audit logs older than 7 days deleted **Export before downgrading!** ### From Enterprise to Pro **Contact support:** * Cannot self-downgrade from Enterprise * Email [support@knoxcall.com](mailto:support@knoxcall.com) * Contract termination may apply ## Billing ### Payment Methods **Accepted:** * 💳 Credit cards (Visa, Mastercard, Amex) * 💳 Debit cards * 🏦 ACH bank transfer (Enterprise only) * 📄 Invoicing (Enterprise only) **Powered by Stripe:** * Secure payment processing — card details are entered on Stripe and never reach KnoxCall * Stripe is a PCI DSS Level 1 service provider * Automatic retries on failure ### Billing Cycle **Starter & Pro Plans:** * **Monthly or annual billing** — annual saves \~17% ($190/yr Starter, $990/yr Pro) * Example: Subscribed Jan 15 → Billed on the 15th of each month (or annually) **Enterprise Plan:** * **Annual billing** (typical) * Custom billing terms available * Volume discounts ### Tax registration ID If your business is registered for GST, VAT, sales tax or a similar scheme, add its registration number and it prints on every invoice. **Where to enter it:** * **At signup** — the organisation step has an optional *Tax registration ID* field under the address. The type (GST number, ABN, VAT number, EIN, …) follows the country you pick. * **Settings → Billing** — *Tax Registration ID* in the subscription summary. Owners and admins can add, change or remove it at any time. * **Settings → Account** — the same field sits under the address, and saves with it, to the workspace you joined first. The page lets you change the address and tax ID there only if you are the Owner or an Admin of every workspace you belong to; otherwise it shows them read-only, and a workspace's Owner or Admins change its tax ID on its Billing page. * **When upgrading from Free** — the Stripe checkout page asks for it too. **What happens next:** * The number is attached to your billing account and appears on invoices from the next one issued. For registries Stripe can check (EU VAT, UK VAT, Australian ABN and others) the Billing page shows *verified*, *verification pending* or *could not be verified*. * Changing the number replaces it on your billing account; clearing the field removes it. * One tax ID per workspace. If you edit it in the Stripe billing portal instead, the Billing page picks up the change. Supported types match [Stripe's list](https://stripe.com/docs/billing/customer/tax-ids); the form only offers the types valid for your billing country. ### Invoices **View invoices:** 1. Navigate to Settings → Billing 2. Click **Billing History** 3. Download PDF invoices **Invoice details:** * Tenant name and address * Tax registration ID (when set) * Billing period * Amount charged * Payment method * Transaction ID ### Failed Payments **If payment fails:** 1. Email notification sent 2. Retry in 3 days 3. Retry in 7 days 4. If still failing: Account suspended **Account suspension:** * Routes stop working * Cannot access admin UI * Data preserved (not deleted) **To reactivate:** 1. Update payment method 2. Payment processed 3. Account reactivated immediately ## Canceling Subscription **To cancel Pro plan:** 1. Navigate to Settings → Billing 2. Click **Cancel Subscription** 3. Confirm cancellation **What happens:** * Billed through end of current period * Pro features remain active until period ends * Then downgrade to Starter automatically **Data preserved:** * All routes, secrets, clients remain * Logs truncated to 7 days after downgrade **To cancel Enterprise:** * Contact [support@knoxcall.com](mailto:support@knoxcall.com) * Contract termination terms apply ## FAQ ### Can I switch between monthly and annual billing? **Starter & Pro Plans:** Yes — both monthly and annual billing are available (annual saves \~17%). Switch via Settings → Billing. **Enterprise Plan:** Custom (annual typical, monthly available) ### What happens to my data if I downgrade? **Immediate:** * All routes, secrets, clients remain * Configuration unchanged **After billing period ends:** * Logs older than 7 days deleted * Analytics truncated * Features disabled **Export data before downgrading.** ### Do you offer discounts? **Startup discount:** * Early-stage startups (under \$1M funding) * 50% off Pro plan for 1 year * Contact [sales@knoxcall.com](mailto:sales@knoxcall.com) **Non-profit discount:** * Registered non-profits * Free Pro plan * Contact [sales@knoxcall.com](mailto:sales@knoxcall.com) **Volume discount:** * Enterprise plans only * Based on annual commit * Discuss with sales ### Can I get a refund? **Starter & Pro Plans:** * 30-day money-back guarantee * Full refund if unsatisfied * Contact [support@knoxcall.com](mailto:support@knoxcall.com) **Enterprise:** * Refund terms in contract ### What counts as a "request"? **Each API call counts:** * Incoming request to KnoxCall subdomain * Forwarded to backend API * Response returned to client **Does NOT count:** * Admin UI usage * Route testing (test mode) * Failed authentication (before routing) ### Can I pay annually for Pro? **Yes** — Pro is $990/year (about 17% off monthly). Starter annual is $190/year. ### What if I hit my monthly API call cap? **Process:** 1. Track usage in Settings → Billing 2. Upgrade any time — the higher cap applies immediately 3. Enterprise removes caps entirely **We're reasonable:** * Spikes during launches: talk to us * Consistent high volume: upgrade needed ## Related Topics * **Tenant Management**: Configure your organization * **Team Collaboration**: Invite team members (all plans) * **Analytics**: View usage metrics ## Next Steps Unlock advanced features Discuss Enterprise plan Billing history Invite team members *** * **Level**: beginner * **Time**: 5 minutes `billing`, `pricing`, `subscription`, `plans`, `payment` # Team Collaboration Source: https://docs.knoxcall.com/account/team-collaboration Invite team members and give each one of four roles: Viewer, Editor, Admin or Owner. Editors can be allowed to reveal secret values. # Team Collaboration Invite team members to work in your KnoxCall workspace. Everyone has one of four fixed roles, the same in every workspace, and an Editor can additionally be allowed to reveal secret values. ## Overview **Team features:** * 👥 Invite team members (up to your plan's limit) * 🔐 Four roles — **Viewer**, **Editor**, **Admin** and **Owner** — plus **Can reveal secret values** for Editors * 📧 Email invitations with secure links * 📊 Audit log tracking of member actions **Perfect for:** * Dev teams managing API integrations * Ops teams monitoring traffic * Security teams auditing access ## Team Roles Every workspace has four roles. The dashboard shows them as **Viewer**, **Editor**, **Admin** and **Owner** everywhere a role appears. ### Owner Exactly one per workspace. Everything an Admin can do, plus: * Make someone an Admin (by invitation or by changing their role), with a fresh two-factor verification. **Only the Owner can grant Admin.** * Change the plan and the payment method (the billing portal), apply coupons, and see invoices. Admins can change the workspace's tax ID too. * Transfer ownership, and delete the workspace * Always sees secret values * Is emailed whenever an Admin is demoted or leaves the workspace: when a person demotes or removes them, when your identity provider deprovisions them through SCIM, and when their KnoxCall account is erased. Each email says which it was. ### Admin Everything an Editor can do, plus: * Invite, remove and change the role of Editors and Viewers * Demote or remove another Admin, with a fresh two-factor verification — the Owner is emailed every time * Turn **Can reveal secret values** on for an Editor, with a fresh two-factor verification (the Editor and the Owner are emailed) * Create API keys, OAuth apps and agents * Set up single sign-on, encryption keys, vaults, PKI and database-credential connections * Change Integrations, Sending domains and Deployment * Change the workspace's tax ID (Settings → Billing) * Read request-log bodies, decrypt, detokenize, issue certificates and mint database credentials in the dashboard * See audit log change details * Test a route from the dashboard * Always sees secret values ❌ **Cannot do:** * Make someone an Admin (the Owner only) * Change the plan or the payment method, apply coupons, see invoices, or delete or transfer the workspace (the Owner only) **Use cases:** * Engineering leads * DevOps engineers * Technical founders ### Editor **Builds and runs integrations:** ✅ **Can do:** * Create, edit and delete routes, clients, environments, workflows, webhooks and alerts * Set and rotate secret values — write-only: setting a value never shows it * Change the workspace's email branding and email templates (the dashboard shows them under Settings → Integrations, which only Admins and the Owner open) * View logs and analytics ❌ **Cannot do:** * Invite, remove or change the role of anyone * Create API keys, OAuth apps or agents * Set up SSO, encryption keys, vaults or PKI * Change Integrations (other than email branding and email templates), Sending domains or Deployment * Change the plan, the payment method or the tax ID, apply coupons, or delete the workspace * View secret values — unless an Admin or the Owner has turned on **Can reveal secret values** for them (below) **Without Can reveal secret values, an Editor cannot view secret values in the dashboard — that is a limit on viewing, not on sending.** An Editor can edit routes and workflows, and both inject your secrets into the requests they send, so editing a route or workflow that uses a secret can send that secret to a different host. Give the Editor role to people you trust with the credentials those routes and workflows use. **Use cases:** * Backend developers * Integration engineers * QA engineers ### Can reveal secret values (an Editor setting) An Admin or the Owner can allow one Editor to see secret values. On the Team page it is the **Can reveal secrets** column: a checkbox for each Editor, **Always** for Admins and the Owner, **Never** for Viewers. With it on, that Editor can: * Reveal a secret's value (the show/hide control on a secret) * View or download a certificate secret (with or without its private key) * Reveal a secret shared from another workspace * Run workflow tests that use stored credentials * See a workflow's webhook trigger URL It does not reach anything else an Admin can see (vault detokenization, transit decryption, request and webhook payloads, imported-credential review). **Every reveal asks for a fresh second factor, for everyone** — the Owner, an Admin, or an Editor with this setting: an authenticator code, a passkey or a security key (never an emailed code), once per reveal. Each reveal is recorded in the audit log as `plaintext.reveal` before the value is shown, with an optional reason you can type when you reveal. Reveals are rate-limited per person, and while the limiter is unavailable they are refused rather than allowed. This controls who can VIEW values. Anyone who can edit a route or workflow that uses a secret can still direct it to a host they control. * Turning it **on** needs an Admin or the Owner and a fresh verification with a security key or authenticator code; the Editor and the Owner are both emailed. Nobody can turn it on for themselves. * Turning it **off** needs no verification: an Admin, the Owner, or the Editor themselves can do it at any time. An Editor who has it turns it off under **Settings → Account** (**Stop revealing secret values**). * Changing a person's role turns it off. In the **Change role** dialog you can tick it again for an Editor in the same step; that is saved as a second change, after the new role, and asks for the verification. * Viewers can never have it; Admins and the Owner do not need it. * Single sign-on and SCIM provisioning never turn it on, and an invitation never carries it — it is granted after the person has joined. * The command line (`knoxcall` CLI) never shows plaintext, whatever the role. The **Developer** role is retired: every Developer became an Editor with Can reveal secret values turned on, so nobody lost access, and it can no longer be assigned. ### Viewer **Read-only access:** ✅ **Can do:** * View routes, clients, environments, workflows, webhooks and alerts, and secret names (never their values) * View request-log metadata and analytics ❌ **Cannot do:** * Create, edit or delete anything * View secret values * Test routes * Configure settings, or open the Team, Billing, Referrals, Integrations, Sending domains and Deployment settings (Admins and the Owner only) **Use cases:** * Product managers * Customer success * External auditors ## Compare roles One table, the same one the dashboard shows on the Team page, the invite page and each member's page. | Capability | Viewer | Editor | Admin | Owner | | - | - | - | - | - | | View configuration, secret names and request-log metadata | ✓ | ✓ | ✓ | ✓ | | Edit routes, clients, environments, workflows, webhooks and alerts | — | ✓ | ✓ | ✓ | | Set and rotate secret values (Write-only: setting a value never shows it) | — | ✓ | ✓ | ✓ | | Reveal secret values and download certificate secrets (Dashboard only; asks for step-up every time) | — | If ticked | ✓ | ✓ | | Read request-log bodies, decrypt, detokenize, issue certificates, mint DB credentials (Dashboard only; the CLI refuses them) | — | — | ✓ | ✓ | | Audit log change details | — | — | ✓ | ✓ | | Create API keys, OAuth apps and agents | — | — | ✓ | ✓ | | Set up SSO, encryption keys, vaults and PKI | — | — | ✓ | ✓ | | Change integrations, sending domains and deployment (Except email branding and email templates, which Editors can change too) | — | — | ✓ | ✓ | | Invite and remove people, and change their roles (Only the Owner can make Admins. Moving or removing an Admin asks for step-up and emails the Owner) | — | — | ✓ | ✓ | | Make someone an Admin | — | — | — | ✓ | | Change the plan and payment method, apply coupons, see invoices, delete or transfer the workspace (Admins change the tax ID) | — | — | — | ✓ | Plaintext stays in the dashboard. Signing in with the CLI gives you your role's permissions, but the CLI never returns secret values or decrypted data, whatever your role. Revealing a secret value or downloading a certificate secret asks for your security key or authenticator code every time, and every reveal writes an audit row. ## Inviting Team Members ### Step 1: Navigate to Team Page 1. Click **Settings** in sidebar 2. Select **Team** 3. See current team members ### Step 2: Invite Member 1. Click **+ Invite member** 2. Enter email address: ``` sarah@example.com ``` 3. Select role: ``` ○ Viewer ◉ Editor ○ Admin (the Owner only) ``` 4. Click **Send invitation** Only the Owner can invite an Admin, and doing so asks for a fresh two-factor verification. An Admin invitation sent by anyone else before this rule existed is cancelled when it is accepted, and its sender is emailed. Invitations carry a role only. **Can reveal secret values** can be ticked once the person has joined. ### Step 3: Invitation Sent **Email sent to invited user:** ```text theme={"dark"} Subject: You've been invited to join DunderMifflin on KnoxCall Hi, John (john@example.com) has invited you to join the DunderMifflin team on KnoxCall as an Editor. [Accept Invitation] This invitation expires in 7 days. ``` ### Step 4: User Accepts **New user:** 1. Clicks "Accept Invitation" 2. Creates KnoxCall account 3. Gains access to the workspace **Existing user:** 1. Clicks "Accept Invitation" 2. Logs in with existing account 3. Workspace added to their account 4. Can switch between workspaces ## Managing Team Members ### Viewing Team **Team page shows:** * Member name and email * Role (Owner, Admin, Editor, Viewer) * Can reveal secrets (a checkbox for Editors; Always for Admins and the Owner; Never for Viewers) * Joined date * Pending invitations, with their role ### Changing Member Role 1. Navigate to Settings → Team 2. Open the member's row actions (⋯) and choose **Change role** 3. Pick **Viewer**, **Editor** or **Admin** — for an Editor, tick **Can reveal secret values** if they should see values 4. Click **Save role** Making someone an Admin, or moving an Admin to another role, asks for your security key or authenticator code, and moving an Admin emails the Owner. Ticking reveal asks too, so moving an Admin to Editor with it ticked asks twice. The Owner's role changes only by transferring ownership. **Effect:** Immediate (the member's next request reflects the new role) ### Removing Team Member 1. Navigate to Settings → Team 2. Open the member's row actions (⋯) and choose **Remove** 3. Confirm removal **What happens:** * The member loses access to the workspace immediately * Audit logs still show their past actions * Removing an Admin asks for a fresh verification, and the Owner is emailed ### Resending Invitation **If an invitation expired or the email was lost:** cancel it and invite the same address again. **Invitation expiration:** 7 days ### Canceling Invitation **Before user accepts:** 1. Navigate to Settings → Team 2. Find the pending invitation 3. Choose **Cancel invite** 4. The invitation link becomes invalid ## Audit Logging All team member actions are logged: **View team member activity:** 1. Navigate to Monitoring → Audit Logs 2. Filter by user email 3. See all changes made by that member **Example log entries:** ```text theme={"dark"} john@example.com created route "stripe-payments" sarah@example.com updated secret "api-key" (added new version) mike@example.com deleted client "test-client" ``` **Use cases:** * Compliance audits * Troubleshooting configuration issues * Security investigations ## Best Practices ### 1. Principle of Least Privilege **Assign minimum necessary role:** ✅ **Good:** ```text theme={"dark"} Product Manager: Viewer Backend Dev: Editor (can configure) Engineering Lead: Admin ``` ❌ **Bad:** ```text theme={"dark"} Everyone: Admin ``` ### 2. Regular Access Reviews **Monthly:** * Review team member list * Remove inactive members * Verify roles, and who can reveal secret values, are still appropriate * Check for ex-employees ### 3. Use Service Accounts for Automation **Don't share personal accounts for CI/CD:** ❌ **Bad:** Use [john@example.com](mailto:john@example.com) credentials in CI ✅ **Good:** Register a dedicated OAuth client for CI/CD (or use workload identity federation — zero stored secrets) **Team members = humans only** ### 4. Document Team Structure **Maintain team roster:** ```text theme={"dark"} Admins: - john@example.com (Engineering Lead) - sarah@example.com (DevOps Lead) Editors: - mike@example.com (Backend Dev) - lisa@example.com (QA Engineer) Viewers: - alex@example.com (Product Manager) ``` ### 5. Onboarding/Offboarding Process **New team member:** 1. Invite to the KnoxCall workspace 2. Share documentation links 3. Walkthrough key routes/secrets 4. Add to on-call rotation (if applicable) **Team member leaving:** 1. Remove from the KnoxCall workspace 2. Rotate any shared secrets they had access to 3. Review their audit logs (last actions) 4. Update on-call rotation ## Multiple Workspaces **If user belongs to multiple workspaces:** **Switch workspaces:** 1. Click the workspace dropdown (top-left) 2. Select a different workspace 3. UI updates to that workspace **Each workspace:** * Separate team with separate roles * User may be an Admin in one, a Viewer in another * Permissions apply per workspace **Example:** ```text theme={"dark"} john@example.com ├─ DunderMifflin-Prod: Admin ├─ DunderMifflin-Dev: Editor └─ ClientProject: Viewer ``` ## Troubleshooting ### Issue: "Invitation not received" **Causes:** * Email in spam folder * Typo in email address * Email server rejecting **Fix:** 1. Check spam folder 2. Verify correct email 3. Cancel the invitation and invite again 4. Try different email provider ### Issue: "Can't remove team member" **Causes:** * Trying to remove yourself (not allowed) * Trying to remove the Owner (transfer ownership first) **Fix:** * Have an Admin or the Owner remove you * The Owner transfers ownership, then the former Owner (now an Admin) can be removed ### Issue: "Permissions not working" **Cause:** The dashboard still shows the role you had when the page loaded **Fix:** 1. Reload the page 2. If it persists, log out and log back in ## Related Features * **Audit Logs**: Track team member actions * **Tenant Management**: Overall workspace settings * **API Keys**: Alternative authentication (not tied to a user), each with one of four permission presets ## Next Steps Track team activity Configure tenant Start configuring routes Manage credentials *** * **Level**: beginner * **Time**: 5 minutes `team`, `collaboration`, `permissions`, `access-control` # Tenant Management Source: https://docs.knoxcall.com/account/tenant-management Manage your KnoxCall tenant settings, base URL, subdomain, and organization details. Control tenant-level configuration and security. # Tenant Management Configure your KnoxCall tenant settings, subdomain, and organization details. ## What is a Tenant? A **tenant** is your organization's isolated KnoxCall instance: * 🏢 Dedicated subdomain: `{slug}.knoxcall.com` * 🔒 Isolated data (routes, secrets, clients) * 👥 Team members with specific access * 📊 Separate analytics and logs * 💳 Individual subscription plan **Multi-tenancy:** You can create multiple tenants for different projects, clients, or environments. ## Tenant Settings Navigate to **Settings** → **Profile** to manage tenant settings. The settings sidebar is **deployment-mode aware**. Cloud-only sections — **Integrations**, **Deployment**, **Billing**, **Team**, and **Referrals** — are hidden when running a self-hosted KnoxCall instance. The sections visible to you depend on your `DEPLOYMENT_MODE` environment variable. ### Tenant Name **Display name** for your organization **Example:** ```text theme={"dark"} Dunder Mifflin Paper Company ``` **Used in:** * Admin UI header * Email notifications * Invoices **To change:** 1. Navigate to Settings → Profile 2. Edit "Tenant Name" field 3. Click Save ### Tenant Slug **URL-friendly identifier** for your tenant **Example:** ```text theme={"dark"} DunderMifflin ``` **Used in subdomain:** ```text theme={"dark"} https://DunderMifflin.knoxcall.com ``` **To change:** 1. Navigate to Settings → Profile 2. Edit "Slug" field 3. Click Save **Note:** Changing slug updates your base URL. Clients must update to new URL. ### Base URL **Your unique KnoxCall proxy URL:** ```text theme={"dark"} https://{slug}.knoxcall.com/api ``` **Example:** ```text theme={"dark"} https://DunderMifflin.knoxcall.com/api ``` This is where clients send proxy requests. Append the route path after `/api/`, e.g. `https://DunderMifflin.knoxcall.com/api/v1/users`. The same `{slug}.knoxcall.com` host serves the admin UI (browser) and the proxy API (under `/api/`). API requests are authenticated with a short-lived OAuth 2.1 access token (`Authorization: Bearer kc_…`) or the legacy `x-knoxcall-key` header — see [Authentication](/api-reference/authentication). ## Profile Photo and Address The account profile page (Settings → Profile) supports photo upload and address fields. ### Profile photo * **Upload**: click your current avatar → **Upload photo** (JPEG or PNG, max 2 MB) * **Remove**: hover over your avatar → click **Remove photo** Your profile photo appears in the admin UI header and in team membership lists. ### Address fields Address information is used for billing and compliance: | Field | Notes | | - | - | | **Country** | Required for billing — printed on invoices, and it decides which tax registration ID types are offered | | **Street address** | Autocompletes via Google Places | | **City** | Auto-filled from Google Places selection | | **Postal code** | Auto-filled from Google Places selection | | **Tax registration ID** | Optional. GST number, ABN, VAT number, EIN or the equivalent for your country — printed on invoices. Also editable under Settings → Billing. See [Subscription plans → Tax registration ID](/account/subscription-plans#tax-registration-id) | Type your address in the street field and select from the Google Places autocomplete dropdown to fill all address fields at once. ## Tenant Features Different subscription plans include different features: ### Starter Plan * ✅ Unlimited routes * ✅ Basic analytics (7 days) * ✅ Email alerts * ✅ API logs (7 days) ### Pro Plan * ✅ Everything in Starter * ✅ Advanced analytics (30 days) * ✅ SMS + Slack alerts * ✅ Log export (CSV) * ✅ Real-Time Geo ### Enterprise Plan * ✅ Everything in Pro * ✅ Extended retention (90 days) * ✅ Audit logs (1 year) * ✅ Custom integrations * ✅ Priority support **View your plan:** Settings → Billing ## Tenant Deletion **⚠️ DESTRUCTIVE:** Deleting tenant is permanent! **What's deleted:** * All routes and configurations * All secrets (encrypted credentials) * All clients and IP whitelists * All API keys * All logs and analytics * All team members * All billing history **Cannot be undone!** **To delete:** 1. Navigate to Settings → Profile 2. Scroll to "Danger Zone" 3. Click "Delete Tenant" 4. Type tenant name to confirm 5. Click "I understand, delete this tenant" **Recommendations:** * Export all data before deletion * Revoke all API keys * Notify team members * Cancel subscription ## Multi-Tenant Setup **Use cases for multiple tenants:** ### Scenario 1: Separate Dev/Staging/Production ```text theme={"dark"} Tenant 1: DunderMifflin-Dev (testing) Tenant 2: DunderMifflin-Staging (pre-prod) Tenant 3: DunderMifflin-Prod (live) ``` **Benefits:** * Isolated configurations * No risk of prod changes during dev * Separate team access ### Scenario 2: Multiple Clients/Projects ```text theme={"dark"} Tenant 1: ClientA-Integration Tenant 2: ClientB-Integration Tenant 3: Internal-APIs ``` **Benefits:** * Client isolation * Separate billing * Per-client customization ### Scenario 3: Regional Tenants ```text theme={"dark"} Tenant 1: Company-US Tenant 2: Company-EU Tenant 3: Company-Asia ``` **Benefits:** * Geographic compliance (GDPR, etc.) * Regional routing * Performance optimization ## Switching Between Tenants **If you have access to multiple tenants:** 1. Click tenant dropdown (top-left of admin UI) 2. Select different tenant 3. UI updates to show selected tenant's data **Each tenant has:** * Own routes, secrets, clients * Own team members * Own billing ## Best Practices ### 1. Use Descriptive Names ✅ **Good:** Dunder Mifflin Production ❌ **Bad:** Tenant 1 ### 2. Protect Production Tenants **Recommendations:** * Limit team member access * Enable all security features * Regular audit log reviews * Never test in production tenant ### 3. Document Tenant Purpose **Maintain documentation:** ```text theme={"dark"} Tenant: DunderMifflin-Prod Purpose: Production API proxy for customer-facing apps Team: ops@example.com, john@example.com Subdomain: https://a1b2c3d4.DunderMifflin.knoxcall.com Critical Routes: payments, orders, fulfillment ``` ### 4. Regular Security Reviews **Monthly:** * Review team member access * Check API key usage * Audit configuration changes * Verify secrets rotated ### 5. Plan for Tenant Migration **If moving to new tenant:** 1. Create new tenant 2. Replicate routes, secrets, clients 3. Test thoroughly in new tenant 4. Update clients to new subdomain 5. Monitor old tenant for stragglers 6. Deprecate old tenant after grace period ## Related Features * **Team Management**: Invite team members to tenant * **API Keys**: Manage tenant-level authentication * **Subscription**: Billing and plan management ## Next Steps Invite team members Manage billing Create authentication keys Configure your routes *** * **Level**: beginner * **Time**: 5 minutes `tenant`, `organization`, `admin`, `settings` # Advanced Route Configuration Source: https://docs.knoxcall.com/advanced/advanced-route-configuration Master advanced route features including header injection, body injection, method-specific configs, and secret injection. # Advanced Route Configuration Take full control of your API routing with advanced configuration options including header injection, body injection, method-specific behaviors, and secret injection. ## Header Injection Automatically add or modify headers on all requests forwarded to your backend. ### Static Headers Add fixed headers to every request: ```json theme={"dark"} { "X-API-Version": "v2", "X-Service-Name": "knoxcall-proxy" } ``` **Configuration:** 1. Edit route → **Config** tab → **Header Injection** section 2. Add headers in JSON format 3. Save **Example request:** ```bash theme={"dark"} # Client sends: curl https://a1b2c3d4.acme.knoxcall.com/api/users \ -H "x-knoxcall-route: my-route" # KnoxCall forwards with injected headers: GET /api/users X-API-Version: v2 X-Service-Name: knoxcall-proxy ``` ### Secret Injection in Headers Inject encrypted secrets as headers using the `{{secret:name}}` syntax: ```json theme={"dark"} { "Authorization": "Bearer {{secret:api_token}}", "X-API-Key": "{{secret:stripe_key}}", "X-Custom-Auth": "{{secret:custom_credential}}" } ``` **KnoxCall automatically:** * Retrieves secret from encrypted vault * Decrypts at request time * Injects into header * Never logs the actual value You can also reference secrets by ID using `{{secret_id:uuid}}` syntax. ### Multiple Secrets Use multiple secrets in a single header configuration: ```json theme={"dark"} { "Authorization": "Bearer {{secret:primary_key}}", "X-Webhook-Secret": "{{secret:webhook_secret}}", "X-Partner-Key": "{{secret:partner_api_key}}" } ``` All secrets are decrypted and injected in a single pass. *** ## Body Injection Inject fields into JSON request bodies. This merges your injected fields with the client's original request body. ### Static Fields Add fixed fields: ```json theme={"dark"} { "source": "knoxcall", "version": "1.0", "metadata": { "proxied": true } } ``` **Original request body:** ```json theme={"dark"} { "username": "john", "email": "john@example.com" } ``` **Forwarded body:** ```json theme={"dark"} { "username": "john", "email": "john@example.com", "source": "knoxcall", "version": "1.0", "metadata": { "proxied": true } } ``` ### Secret Injection in Body Inject secrets into request bodies: ```json theme={"dark"} { "api_key": "{{secret:printnode_key}}", "client_secret": "{{secret:oauth_client_secret}}", "credentials": { "username": "{{secret:service_username}}", "password": "{{secret:service_password}}" } } ``` Body injection only works with JSON payloads. Non-JSON bodies are passed through unchanged. ### Nested Field Injection Inject into nested structures: ```json theme={"dark"} { "auth": { "type": "bearer", "token": "{{secret:bearer_token}}" }, "options": { "timeout": 30, "retry": true } } ``` *** ## Method-Specific Configuration Configure different behaviors per HTTP method. This lets you use different credentials and settings for reads vs. writes. ### Setup 1. Edit route → **Config** tab → **Method Configurations** section 2. Add configuration for each method 3. Configure headers, body, secrets per method ### Example: Read/Write Separation **GET requests** — read-only key: ```json theme={"dark"} { "Authorization": "Bearer {{secret:read_only_key}}" } ``` **POST/PUT requests** — write key: ```json theme={"dark"} { "Authorization": "Bearer {{secret:write_key}}" } ``` **DELETE requests** — admin key with signature: ```json theme={"dark"} { "Authorization": "Bearer {{secret:admin_key}}" } ``` ### Benefits * **Security**: Use different credentials per action * **Rate Limiting**: Restrict writes more than reads * **Auditing**: Track who performs each action type * **Compliance**: Enforce stricter rules on destructive operations See [Method-Specific Config](/advanced/method-specific-config) for a full guide. *** ## Environment Overrides Configure different target URLs, headers, and secrets per environment. This is managed from the **Environment** tab on the route detail page. ### How It Works **Base route (production):** ```json theme={"dark"} Target: https://api.production.com Headers: { "Authorization": "Bearer {{secret:prod_key}}" } ``` **Development override:** ```json theme={"dark"} Target: https://localhost:3000 Headers: null (inherited from base) ``` **Staging override:** ```json theme={"dark"} Target: https://api.staging.com Headers: { "Authorization": "Bearer {{secret:staging_key}}" } ``` `NULL` values inherit from the base configuration. See [Environments](/getting-started/first-environment) for a full guide. *** ## Best Practices ### 1. Always Use Secrets for Sensitive Data ❌ **Bad:** ```json theme={"dark"} { "Authorization": "Bearer sk_live_EXAMPLE_abc123xyz789" } ``` ✅ **Good:** ```json theme={"dark"} { "Authorization": "Bearer {{secret:api_token}}" } ``` ### 2. Use Method-Specific Configs Different security for different operations: ```text theme={"dark"} GET: read_only_key POST/PUT/DELETE: write_key with signature required ``` ### 3. Test in Staging First Always verify configuration changes in a staging environment before deploying to production. ### 4. Keep Header Injection Simple Only inject headers your backend actually needs. Don't add unnecessary headers that could cause conflicts. *** ## Next Steps Deep dive into method configs Learn about secrets Configure rate limits Add signature verification *** * **Level**: advanced * **Time**: 15 minutes `advanced`, `configuration`, `headers`, `body-injection`, `secrets` # KnoxCall AI Integration Guide Source: https://docs.knoxcall.com/advanced/ai-integration-guide Explicit implementation details for integrating APIs with KnoxCall. Covers routing, path concatenation, code changes, and common mistakes. # KnoxCall AI Integration Guide > **For AI Assistants:** This guide provides explicit implementation details for helping developers integrate their APIs with KnoxCall. Read this carefully to avoid common mistakes. ## What is KnoxCall? KnoxCall is an **API proxy** that sits between your application and external APIs. It: * Routes API calls through a central proxy * Injects credentials/secrets automatically * Logs all requests for monitoring * Manages API keys across environments ## Critical Architecture Facts **IMPORTANT:** Understanding how KnoxCall routing works is critical to correct implementation. ### Route Identification * ✅ Routes are identified by the `x-knoxcall-route` header * ❌ Routes are **NOT** identified by path matching or URL patterns * **Code proof:** `const routeName = req.headers['x-knoxcall-route']` ### Path Concatenation * KnoxCall concatenates: `target_base_url.pathname + client_request.path` * **Implementation:** `combinedPath = targetBase.pathname.replace(/\/$/, '') + clientPath` * The client path is **APPENDED as-is** to the target base path * Paths are **NOT stripped, matched, or modified** ### What This Means If a route's `target_base_url` is `https://api.example.com/v1/users`, and you send a request to `https://{slug}.knoxcall.com/123`: * KnoxCall extracts the target base pathname: `/v1/users` * KnoxCall takes your request path: `/123` * KnoxCall concatenates them: `/v1/users` + `/123` = `/v1/users/123` * Final upstream request: `https://api.example.com/v1/users/123` ## How KnoxCall Proxy Works (Step-by-Step) **Example:** Route has `target_base_url: https://api.example.com/integration/patients` 1. **User sends:** `GET https://{slug}.knoxcall.com/123` with header `x-knoxcall-route: ROUTE_ID` 2. **KnoxCall looks up route:** Uses the `x-knoxcall-route` header to find the route configuration (NOT path matching!) 3. **KnoxCall extracts paths:** * Client path from request: `/123` * Target base path from route config: `/integration/patients` 4. **KnoxCall builds final URL:** Concatenates paths = `/integration/patients/123` 5. **KnoxCall injects:** the secret/API key from the route configuration 6. **KnoxCall forwards:** `GET https://api.example.com/integration/patients/123` with injected credentials 7. **Returns:** the response to the user ## Common Mistakes to AVOID ### ❌ Mistake #1: Duplicating the Base Path **WRONG:** ```bash theme={"dark"} # Route target_base_url: https://api.example.com/v1/users # User sends: curl https://{slug}.knoxcall.com/v1/users/123 \ -H 'x-knoxcall-route: route-id' # Result: https://api.example.com/v1/users/v1/users/123 (DUPLICATED!) ``` **CORRECT:** ```bash theme={"dark"} # Route target_base_url: https://api.example.com/v1/users # User sends: curl https://{slug}.knoxcall.com/123 \ -H 'x-knoxcall-route: route-id' # Result: https://api.example.com/v1/users/123 (CORRECT!) ``` ### ❌ Mistake #2: Assuming Path Matching KnoxCall does **NOT** match paths or strip base paths. It simply concatenates: * `target_base_url.pathname` + `your_request.path` = `final_url.pathname` ### ❌ Mistake #3: Including API Keys in Code After integrating with KnoxCall, the API key should be **removed** from your code. KnoxCall injects it automatically. ## The 3 Code Changes Required When integrating an API with KnoxCall, you need to make exactly 3 changes: ### 1. Replace the Base URL Your KnoxCall proxy host is your tenant subdomain: `https://{slug}.knoxcall.com` (replace `{slug}` with your tenant slug; use `https://sandbox-{slug}.knoxcall.com` for the sandbox). **Critical Path Calculation:** * Original API call: `https://api.example.com/v1/users/123` * Route's target\_base\_url: `https://api.example.com/v1/users` * **Step 1:** Remove target\_base\_url from original → `/123` * **Step 2:** KnoxCall URL = proxy domain + what's left → `https://{slug}.knoxcall.com/123` * **Step 3:** Verify → KnoxCall receives `/123`, forwards to `target_base_url + /123` = `https://api.example.com/v1/users/123` ✅ **NEVER include ANY part of the target\_base\_url path in the KnoxCall URL!** ### 2. Add KnoxCall Headers ```http theme={"dark"} Authorization: Bearer # kc_… OAuth access token — see /api-reference/authentication x-knoxcall-route: # The route you configured x-knoxcall-environment: production # Optional; defaults to your tenant's default environment (usually 'production') ``` Mint `` with the OAuth 2.1 `client_credentials` grant, using an OAuth client created in the dashboard (or run `knoxcall login` on a developer machine) — see [Create an OAuth client](/api-reference/authentication#create-an-oauth-client). Legacy client API keys are still supported on the data plane: send `x-knoxcall-key: ` *instead of* the `Authorization` header — never both. ### 3. Remove the Old API Key/Secret KnoxCall injects the API key automatically based on the route configuration. Remove it from: * Request headers * Query parameters * Request body * Authorization headers ## Complete Examples ### Example 1: Python with Query Parameter Auth **Scenario:** Route target\_base\_url is `https://app.scrapingbee.com/api/v1/` **Before:** ```python theme={"dark"} import requests response = requests.get( "https://app.scrapingbee.com/api/v1/", params={ "api_key": SCRAPINGBEE_API_KEY, # Secret in code "url": "https://example.com" } ) ``` **After:** ```python theme={"dark"} import requests # Route's target_base_url already contains /api/v1/ # So we only append what comes after (nothing in this case, so just "/") response = requests.get( "https://{slug}.knoxcall.com/", headers={ "Authorization": f"Bearer {KNOXCALL_ACCESS_TOKEN}", # kc_… OAuth access token "x-knoxcall-route": "abc-123-route-id" }, params={ "url": "https://example.com" # api_key removed - KnoxCall injects it } ) ``` ### Example 2: cURL with Header Auth **Scenario:** Route target\_base\_url is `https://api.stripe.com/v1/customers`, calling `/v1/customers/cus_123` **Before:** ```bash theme={"dark"} curl https://api.stripe.com/v1/customers/cus_123 \ -H 'Authorization: Bearer sk_test_secret_key' ``` **After:** ```bash theme={"dark"} # Original: https://api.stripe.com/v1/customers/cus_123 # Target base: https://api.stripe.com/v1/customers # Path after base: /cus_123 # KnoxCall URL: proxy + /cus_123 curl https://{slug}.knoxcall.com/cus_123 \ -H "Authorization: Bearer $KNOXCALL_ACCESS_TOKEN" \ -H 'x-knoxcall-route: abc-123-route-id' # Stripe key removed - your Authorization header now carries the KnoxCall # OAuth token, and KnoxCall injects the upstream credential ``` ### Example 3: PHP with Complex Path **Scenario:** Route target\_base\_url is `https://api.endoaxis.com/integration/patients`, calling patient ID 789 **Before:** ```php theme={"dark"} $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, "https://api.endoaxis.com/integration/patients/789"); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Authorization: Bearer ' . $api_token ]); $response = curl_exec($ch); ``` **After:** ```php theme={"dark"} // Original: https://api.endoaxis.com/integration/patients/789 // Target base: https://api.endoaxis.com/integration/patients // Path after base: /789 // KnoxCall URL: proxy + /789 $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, "https://{slug}.knoxcall.com/789"); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Authorization: Bearer ' . $knoxcall_token, // kc_… OAuth access token 'x-knoxcall-route: abc-123-route-id' // Upstream API token removed - KnoxCall injects it ]); $response = curl_exec($ch); ``` ## Path Calculation Algorithm When helping a developer convert their API call to KnoxCall, **always follow these steps:** ```text theme={"dark"} Given: - original_url = "https://api.example.com/v1/users/123/posts/456" - target_base_url = "https://api.example.com/v1/users" (from route config) - proxy_domain = "https://{slug}.knoxcall.com" Step 1: Extract the path after target_base_url original_path = "/v1/users/123/posts/456" base_path = "/v1/users" remaining_path = original_path.replace(base_path, "") = "/123/posts/456" Step 2: Build KnoxCall URL knoxcall_url = proxy_domain + remaining_path knoxcall_url = "https://{slug}.knoxcall.com/123/posts/456" Step 3: Verify When KnoxCall receives: "/123/posts/456" It will forward to: target_base_url + "/123/posts/456" Result: "https://api.example.com/v1/users/123/posts/456" ✅ ``` ## Integration Patterns ### Pattern 1: Base Path Only (No Additional Path) ```text theme={"dark"} Original: https://api.example.com/v1/endpoint Route target: https://api.example.com/v1/endpoint KnoxCall call: https://{slug}.knoxcall.com/ ``` ### Pattern 2: Base Path + Resource ID ```text theme={"dark"} Original: https://api.example.com/v1/users/123 Route target: https://api.example.com/v1/users KnoxCall call: https://{slug}.knoxcall.com/123 ``` ### Pattern 3: Base Path + Nested Resources ```text theme={"dark"} Original: https://api.example.com/v1/users/123/orders/456 Route target: https://api.example.com/v1/users KnoxCall call: https://{slug}.knoxcall.com/123/orders/456 ``` ### Pattern 4: Base Path + Query Parameters ```text theme={"dark"} Original: https://api.example.com/v1/search?q=test&limit=10 Route target: https://api.example.com/v1/search KnoxCall call: https://{slug}.knoxcall.com/?q=test&limit=10 ``` ## When to Create Routes Create one route for each unique combination of: * **API endpoint** (base URL) * **Authentication method** (which secret/credentials to use) * **HTTP method** (GET, POST, etc.) - if different methods need different config You can use the same route for multiple resource IDs: * ✅ One route for all Stripe customers: `/v1/customers/*` * ❌ Don't create separate routes for each customer ID ## Environment Support KnoxCall supports multiple environments (development, staging, production): ```bash theme={"dark"} # Explicitly target the production environment # (omit the header to use your tenant's default environment) curl https://{slug}.knoxcall.com/123 \ -H 'x-knoxcall-route: route-id' \ -H 'x-knoxcall-environment: production' # Use staging secrets curl https://{slug}.knoxcall.com/123 \ -H 'x-knoxcall-route: route-id' \ -H 'x-knoxcall-environment: staging' ``` ## Testing Your Integration After making the 3 code changes: 1. **Check the logs** in KnoxCall dashboard to see the actual upstream request 2. **Verify the URL** in logs matches your expected API endpoint 3. **Check for auth errors** - if you see 401/403, the secret might not be injected correctly 4. **Look for path duplication** - if you see `/v1/users/v1/users`, you included the base path in your request ## Advanced: OAuth2 Token Management For OAuth2 APIs, KnoxCall handles token refresh automatically: ```python theme={"dark"} # You don't manage tokens - just make the call response = requests.get( "https://{slug}.knoxcall.com/me", headers={ "Authorization": f"Bearer {KNOXCALL_ACCESS_TOKEN}", "x-knoxcall-route": "oauth-route-id" } ) # KnoxCall automatically: # 1. Checks if token is expired # 2. Refreshes it if needed # 3. Injects the valid token # 4. Forwards your request ``` ## Common Error Messages ### "Route not found" * Check that `x-knoxcall-route` header contains the correct route ID * Verify the route exists in your KnoxCall dashboard ### "Client not authorized" * Check that your `Authorization: Bearer` header contains a valid KnoxCall access token (`kc_…`) — or, if using the legacy path, that the `x-knoxcall-key` header contains your client API key * Verify the client has access to this route ### "Secret not found for environment" * The route's secret doesn't have a value for the requested environment * Add the secret value for that environment or switch environments ### "404 Not Found" from upstream API * Likely path duplication or incorrect path calculation * Check KnoxCall logs to see the actual URL being forwarded * Verify you're not including the base path in your request ## Best Practices for AI Assistants When helping developers integrate with KnoxCall: 1. **Always work through the path calculation step-by-step** - don't guess 2. **Show before/after code diffs** - makes the changes crystal clear 3. **Explicitly point out the removed API key** - security win! 4. **Use the actual route ID** from the route they configured 5. **Verify the math** - trace through what KnoxCall will forward 6. **Don't invent features** - KnoxCall is a proxy, nothing more ## Summary **KnoxCall is ONLY a proxy.** It: * Identifies routes by `x-knoxcall-route` header * Concatenates `target_base_url.pathname + request.path` * Injects secrets automatically * Forwards the request to the upstream API **For the proxy data-plane, there is NO:** * Path matching or pattern matching * Path stripping or modification * SDK or library required to make proxied calls — you just send the headers above * Environment variable injection (KnoxCall does ship official SDKs — Node, Go, PHP, Python, Ruby — and a management Secrets API for configuring routes and secrets, but the proxy data-plane itself needs none of them.) When in doubt, remember: **Header-based routing + path concatenation = final URL** *** *This guide is maintained by the KnoxCall team. For questions or issues, visit [https://docs.knoxcall.com](https://docs.knoxcall.com)* # AI-Powered Route Setup Source: https://docs.knoxcall.com/advanced/ai-route-setup Paste your API request code and let AI extract the configuration automatically. Supports cURL, JavaScript, Python, PHP, and more. # AI-Powered Route Setup Paste your existing API code and let KnoxCall's AI extract the route configuration automatically. ## What is AI-Powered Route Setup? Instead of manually filling in route details, **paste your existing API request code** (cURL, JavaScript, Python, etc.) and KnoxCall's AI will: * 🔍 Extract URL and HTTP method * 📋 Detect headers and content type * 📦 Parse request body structure * 🔐 Identify secrets (API keys, tokens) * 💡 Suggest where secrets should be injected * 🏷️ Recommend a route name * ⚡ Generate the complete route configuration **Perfect for:** Migrating existing API integrations to KnoxCall without rewriting configurations manually. ## Supported Languages KnoxCall's AI parser supports code from: * **cURL** (most common) * **JavaScript** (fetch, axios, node-fetch) * **Python** (requests, urllib, httpx) * **PHP** (cURL, Guzzle, file\_get\_contents) * **Ruby** (net/http, RestClient, HTTParty) * **Go** (net/http, resty) * **Java** (HttpClient, OkHttp, RestTemplate) * **C#** (HttpClient, RestSharp) Even if your language isn't perfectly recognized, the AI can often extract the important details. ## How It Works ```text theme={"dark"} 1. Navigate to Routes → Create Route → Automated Setup ↓ 2. Paste your API request code ↓ 3. AI analyzes the code: - Detects programming language - Extracts URL, method, headers, body - Identifies secrets (API keys, tokens) - Suggests secret injection locations ↓ 4. Review detected configuration: - Route name suggestion - URL and method - Headers and body - Detected secrets (optional to save) ↓ 5. Customize if needed → Create route ``` ## Step-by-Step Guide ### Step 1: Open Create Route Modal 1. Navigate to **Routes** 2. Click **Create Route** button 3. Choose **Automated Setup** ### Step 2: Paste Your Code Copy your existing API request code and paste it: **Example 1: cURL Command** ```bash theme={"dark"} curl -X POST "https://api.stripe.com/v1/charges" \ -H "Authorization: Bearer sk_live_EXAMPLE_abc123xyz789def456" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "amount=1000¤cy=usd" ``` **Example 2: JavaScript (fetch)** ```javascript theme={"dark"} const stripeApiKey = "sk_live_EXAMPLE_abc123xyz789def456"; fetch("https://api.stripe.com/v1/charges", { method: "POST", headers: { "Authorization": `Bearer ${stripeApiKey}`, "Content-Type": "application/json" }, body: JSON.stringify({ amount: 1000, currency: "usd", source: "tok_visa" }) }); ``` **Example 3: Python (requests)** ```python theme={"dark"} import requests api_key = "sk_live_EXAMPLE_abc123xyz789def456" headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json" } payload = { "amount": 1000, "currency": "usd" } response = requests.post( "https://api.stripe.com/v1/charges", headers=headers, json=payload ) ``` **Example 4: PHP (cURL)** ```php theme={"dark"} $apiKey = "sk_live_EXAMPLE_abc123xyz789def456"; $ch = curl_init("https://api.stripe.com/v1/charges"); curl_setopt($ch, CURLOPT_HTTPHEADER, [ "Authorization: Bearer " . $apiKey, "Content-Type: application/json" ]); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([ "amount" => 1000, "currency" => "usd" ])); $response = curl_exec($ch); ``` ### Step 3: Click "Parse & Continue" AI begins analyzing your code. You'll see progress messages: ```text theme={"dark"} 🔍 Analyzing code structure... 📝 Detecting programming language... 🌐 Extracting API endpoint... 📊 Parsing request headers... 🔐 Detecting secrets and credentials... 📦 Analyzing request body... 💡 Generating configuration suggestions... ✅ Analysis complete! ``` This takes 3-5 seconds. ### Step 4: Review Detected Configuration AI shows what it extracted: #### Route Name ```text theme={"dark"} Suggested: stripe-charges ``` *Based on URL path and detected service* You can edit this before creating the route. #### Target URL ```text theme={"dark"} https://api.stripe.com/v1/charges ``` #### HTTP Method ```text theme={"dark"} POST ``` #### Headers ```json theme={"dark"} { "Content-Type": "application/json" } ``` *Non-secret headers are shown here* #### Request Body ```json theme={"dark"} { "amount": 1000, "currency": "usd", "source": "tok_visa" } ``` *Formatted and ready for injection configuration* ### Step 5: Review Detected Secrets (Optional) If AI found secrets in your code: **Detected: `sk_live_EXAMPLE_abc123xyz789def456`** | Property | Value | | - | - | | **Original Variable** | `stripeApiKey` | | **Suggested Name** | `stripe-api-key` | | **Type** | Bearer Token | | **AI Suggestion** | Inject in **Header**: `Authorization: Bearer {{secret:stripe-api-key}}` | **Options:** * ☑️ **Create this secret** (checked by default) * 📝 **Edit secret name** (click to customize) * 👁️ **Show/hide value** (for verification) **Secret values are visible during setup!** Make sure nobody is watching your screen. After clicking "Create Route", the value is encrypted and never shown again. #### AI Insertion Suggestions For each secret, AI recommends: **🤖 AI Suggestion:** * **Location:** Header (or Body) * **Header Name:** `Authorization` (if header) * **JSON Path:** `credentials.token` (if body) * **Description:** "Stripe API authentication token" You don't need to manually configure this - KnoxCall will apply it automatically! ### Step 6: Select Clients (Optional) Choose which client IP addresses can use this route: * [ ] Production Server (52.123.45.67) * [ ] Office Network (192.168.1.0/24) * [x] My Development Machine (203.45.67.89) Or skip and assign clients later. ### Step 7: Create Route Click **Create Route** KnoxCall will: 1. Create the route with extracted configuration 2. Save detected secrets (if selected) 3. Configure header/body injection per AI suggestions 4. Assign selected clients 5. Enable the route Done! 🎉 ## Advanced Features ### Multi-Route Detection If you paste code with **multiple API calls**, AI detects them all: **Example: Multiple endpoints in one script** ```javascript theme={"dark"} // Create customer fetch("https://api.stripe.com/v1/customers", { method: "POST", headers: { "Authorization": `Bearer ${stripeKey}` }, body: JSON.stringify({ email: "customer@example.com" }) }); // Create charge fetch("https://api.stripe.com/v1/charges", { method: "POST", headers: { "Authorization": `Bearer ${stripeKey}` }, body: JSON.stringify({ amount: 1000, currency: "usd" }) }); ``` **AI Result: 2 routes detected** 1. `stripe-customers` → POST to `/v1/customers` 2. `stripe-charges` → POST to `/v1/charges` **Shared secrets detected:** * `stripe-api-key` (used by both routes) You can: * ✅ Select which routes to create * ✅ Create shared secrets once (available to all routes) * ✅ Create all routes in batch **Batch route creation** automatically creates all detected routes with one click. Perfect for migrating entire integrations! ### Secret Reuse If a secret **already exists** in KnoxCall: ```text theme={"dark"} ✓ Secret "stripe-api-key" already exists ``` AI won't create a duplicate. It will use the existing secret in the route configuration. ### Fallback to Manual Parsing If AI can't extract all details (e.g., URL is a variable): ```text theme={"dark"} ⚠️ Could not detect URL ``` You'll be prompted to enter missing details manually before creating the route. **AI still provides:** * Headers * Body structure * Secrets detection You just fill in the URL. ### Language Detection Confidence AI shows confidence level: ```text theme={"dark"} 🔍 Detected: JavaScript (High Confidence) ``` **High confidence:** * Standard library usage (fetch, requests, curl) * Clear syntax * Complete request structure **Medium confidence:** * Custom HTTP library * Partial code snippet * Unusual formatting **Low confidence:** * Ambiguous code * Multiple languages mixed * Very short snippet Even at low confidence, AI usually extracts useful details! ## Example Use Cases ### Use Case 1: Migrate cURL to KnoxCall **Scenario:** You have a cURL command from API documentation **Code:** ```bash theme={"dark"} curl -X POST "https://api.sendgrid.com/v3/mail/send" \ -H "Authorization: Bearer SG.abc123xyz789" \ -H "Content-Type: application/json" \ -d '{ "personalizations": [{"to": [{"email": "recipient@example.com"}]}], "from": {"email": "sender@example.com"}, "subject": "Test Email", "content": [{"type": "text/plain", "value": "Hello!"}] }' ``` **AI Extracts:** * Route name: `sendgrid-mail-send` * URL: `https://api.sendgrid.com/v3/mail/send` * Method: POST * Secret: `sendgrid-api-key` → `SG.abc123xyz789` * Injection: `Authorization: Bearer {{secret:sendgrid-api-key}}` **Result:** Route ready to use immediately! ### Use Case 2: Import Production Code **Scenario:** You're proxying an existing JavaScript API integration **Code:** ```javascript theme={"dark"} async function createCustomer(email) { const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); const customer = await stripe.customers.create({ email: email, description: 'New customer' }); return customer; } ``` **AI Extracts:** * Route name: `stripe-customers-create` * URL: `https://api.stripe.com/v1/customers` (AI knows Stripe SDK) * Method: POST * Secret: `stripe-secret-key` (from environment variable) * Body structure: ```json theme={"dark"} { "email": "{{var:email}}", "description": "New customer" } ``` **Bonus:** AI suggests using `{{var:email}}` for the email parameter! ### Use Case 3: Reverse Engineering API Calls **Scenario:** You found API request in browser DevTools **Code (copied from Network tab):** ```text theme={"dark"} POST /api/v2/orders HTTP/1.1 Host: internal-api.company.com Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... Content-Type: application/json {"product_id": 123, "quantity": 5, "customer_id": 456} ``` **AI Extracts:** * Route name: `orders-create` * URL: `https://internal-api.company.com/api/v2/orders` * Method: POST * Secret: `jwt-token` → `eyJhbGci...` * Body structure: `{"product_id": 123, "quantity": 5, "customer_id": 456}` Perfect for setting up proxies to internal APIs! ### Use Case 4: Python Script Migration **Scenario:** Migrating Python script to run through KnoxCall **Code:** ```python theme={"dark"} import requests import os twilio_account_sid = os.getenv("TWILIO_ACCOUNT_SID") twilio_auth_token = os.getenv("TWILIO_AUTH_TOKEN") url = f"https://api.twilio.com/2010-04-01/Accounts/{twilio_account_sid}/Messages.json" auth = (twilio_account_sid, twilio_auth_token) data = { "From": "+15551234567", "To": "+15559876543", "Body": "Hello from KnoxCall" } response = requests.post(url, auth=auth, data=data) ``` **AI Extracts:** * Route name: `twilio-messages` * URL: `https://api.twilio.com/2010-04-01/Accounts/{{secret:twilio-account-sid}}/Messages.json` * Method: POST * Secrets: * `twilio-account-sid` * `twilio-auth-token` * Authentication: Basic Auth (both secrets) * Body: Form data with From/To/Body **Note:** AI even detects Basic Auth pattern! ## Secret Detection Patterns AI recognizes secrets in many forms: ### Variable Assignments ```javascript theme={"dark"} const apiKey = "sk_live_abc123"; const token = "Bearer xyz789"; ``` ### Environment Variables ```python theme={"dark"} api_key = os.getenv("STRIPE_KEY") ``` ```javascript theme={"dark"} const key = process.env.API_KEY; ``` ```php theme={"dark"} $key = $_ENV['API_KEY']; ``` ### Headers ```bash theme={"dark"} -H "Authorization: Bearer sk_live_abc123" -H "X-API-Key: xyz789" ``` ### URL Parameters ```text theme={"dark"} https://api.service.com?api_key=abc123 ``` ### Basic Auth ```bash theme={"dark"} -u username:password ``` ```python theme={"dark"} auth=('username', 'password') ``` ### Hardcoded Values ```javascript theme={"dark"} fetch("https://api.example.com", { headers: { "Authorization": "Bearer sk_live_abc123" } }); ``` ## Customizing After AI Analysis You can edit any detected field before creating the route: ### Edit Route Name Click the route name field and type a new name: ```text theme={"dark"} AI: stripe-charges You: production-stripe-payment-api ``` ### Edit Target URL Update the URL if needed: ```text theme={"dark"} AI: https://api.stripe.com/v1/charges You: https://api.eu.stripe.com/v1/charges ``` ### Edit Secret Names Click secret name to customize: ```text theme={"dark"} AI: stripe-api-key You: stripe-production-key-2025 ``` ### Toggle Secret Creation Uncheck secrets you don't want to create: * ☑️ stripe-api-key (create) * ☐ webhook-secret (skip - I'll add later) ### Edit Headers Add or remove headers from the detected set: ```json theme={"dark"} { "Content-Type": "application/json", "X-Custom-Header": "my-value" } ``` ### Edit Body Structure Modify the request body before creating: ```json theme={"dark"} { "amount": 1000, "currency": "usd", "metadata": { "order_id": "{{var:order_id}}" } } ``` Add `{{var:param}}` for dynamic values! ## Troubleshooting ### Issue: "Could not detect language" **Causes:** * Very short code snippet * Unusual syntax * Multiple languages mixed **Fix:** 1. Include more context (e.g., full function, not just URL) 2. Add comments indicating language: ```javascript theme={"dark"} // JavaScript fetch("https://api.example.com") ``` 3. Use **Manual Setup** instead ### Issue: "URL not detected" **Symptoms:** AI couldn't find the API endpoint **Common causes:** ```javascript theme={"dark"} // URL is a variable const url = buildApiUrl(); // AI can't resolve this fetch(url); ``` **Fix:** 1. Paste code where URL is defined: ```javascript theme={"dark"} const baseUrl = "https://api.example.com"; const url = baseUrl + "/endpoint"; fetch(url); ``` 2. Or enter URL manually in the form ### Issue: "Secrets detected but values wrong" **Symptoms:** AI detected a secret but the value is incorrect **Example:** ```javascript theme={"dark"} const key = getKeyFromVault(); // AI sees "getKeyFromVault()" as value ``` **Fix:** 1. Edit the secret value after AI analysis 2. Or uncheck and create secret manually later ### Issue: "Too many false positive secrets" **Symptoms:** AI flagged non-secret values as secrets **Example:** ```javascript theme={"dark"} const customerId = "cus_abc123"; // Detected as secret (not actually) ``` **Fix:** 1. Uncheck secrets you don't want to create 2. Only select actual secrets (API keys, tokens) ### Issue: "Request body not formatted" **Symptoms:** Body appears as one long line **Fix:** AI auto-formats JSON. If it's not formatted: 1. Check if body is valid JSON 2. Try pasting just the JSON object: ```json theme={"dark"} { "key": "value" } ``` 3. Or manually format in the body textarea after creation ## Best Practices ### 1. Include Full Context ✅ **Good:** ```javascript theme={"dark"} const stripeKey = "sk_live_abc123"; fetch("https://api.stripe.com/v1/charges", { method: "POST", headers: { "Authorization": `Bearer ${stripeKey}` }, body: JSON.stringify({ amount: 1000 }) }); ``` *Complete request with variable definitions* ❌ **Bad:** ```javascript theme={"dark"} fetch(url, options); ``` *Not enough context* ### 2. Paste Real Values (During Setup) AI needs to see actual secrets to detect them: ✅ **Use:** ```javascript theme={"dark"} const key = "sk_live_EXAMPLE_abc123xyz789"; ``` ❌ **Don't use:** ```javascript theme={"dark"} const key = "YOUR_API_KEY_HERE"; ``` *Placeholders won't be detected as secrets* **Remember:** You can delete the value after route creation! ### 3. Clean Up Comments Remove unnecessary comments that might confuse AI: ✅ **Good:** ```python theme={"dark"} api_key = "sk_live_abc123" response = requests.get(url, headers={"Authorization": f"Bearer {api_key}"}) ``` ❌ **Bad:** ```python theme={"dark"} # TODO: Replace this with your key api_key = "sk_live_abc123" # Don't commit this! # This is just for testing response = requests.get(url, headers={"Authorization": f"Bearer {api_key}"}) # Fix later ``` ### 4. One API Endpoint Per Paste (Usually) For single route creation: * Paste code for ONE endpoint * Remove unrelated code For multi-route creation: * Paste multiple endpoints if they're related * AI will detect and offer batch creation ### 5. Verify AI Suggestions Always review: * ✓ URL is correct * ✓ Secrets are real secrets (not placeholder strings) * ✓ Headers look right * ✓ Body structure matches your needs **Don't blindly accept everything!** AI is smart but not perfect. ### 6. Test After Creation 1. Create route with AI setup 2. Immediately test with Route Tester 3. Verify secrets injected correctly 4. Check backend receives proper requests See [Testing Routes](/essentials/routes/testing-routes) for details. ## Limitations ### Cannot Detect Dynamic URLs If URL is built programmatically: ```javascript theme={"dark"} const url = buildUrl(config.apiEndpoint, params); ``` AI can't resolve this. You'll need to manually enter the URL. ### Cannot Resolve Environment Variables If secret is in environment: ```python theme={"dark"} key = os.getenv("API_KEY") ``` AI detects that a secret is needed but doesn't know the actual value. You'll need to manually create the secret. ### Limited SDK Support If using high-level SDKs: ```javascript theme={"dark"} const stripe = new Stripe(apiKey); await stripe.customers.create({ email }); ``` AI might not know the exact API endpoint being called. It tries to infer but may need manual correction. ### Arrays in Body Not Always Detected Complex nested arrays: ```json theme={"dark"} { "items": [ {"id": 1, "nested": {"deep": {"array": [1, 2, 3]}}} ] } ``` May not parse perfectly. Review and adjust body structure after AI analysis. ### No Support for GraphQL Yet GraphQL queries aren't currently supported: ```graphql theme={"dark"} query { user(id: 123) { name email } } ``` Use Manual Setup for GraphQL endpoints. ## Related Features * **Manual Route Setup**: Traditional step-by-step route creation * **Visual Body Editor**: Drag-and-drop configuration for complex JSON * **Route Testing**: Test AI-generated routes before going live * **Secret Management**: Manage detected secrets after creation ## Next Steps Test AI-generated routes Configure detected secrets Refine body injection visually Create routes manually *** * **Level**: beginner to intermediate * **Time**: 5 minutes `ai`, `automation`, `setup`, `code-parsing`, `migration` # Method-Specific Route Configuration Source: https://docs.knoxcall.com/advanced/method-specific-config Configure different headers, body templates, and secrets for each HTTP method (GET, POST, PUT, DELETE, etc.) # Method-Specific Route Configuration Configure different headers, body templates, and security settings for each HTTP method on a single route. ## What are Method Configs? **Method configs** let you customize route behavior per HTTP method. For example: * **GET requests**: Inject read-only API key * **POST requests**: Inject write API key + additional auth * **DELETE requests**: Require admin API key + signature All on the **same route** with **same URL**! ## Why Use Method Configs? ### Use Case 1: Different API Keys Per Method Your API uses different keys for read vs write operations: ```text theme={"dark"} GET /api/users → Read-only key (safe) POST /api/users → Write key (more powerful) DELETE /api/users → Admin key (most powerful) ``` ### Use Case 2: Method-Specific Headers ```text theme={"dark"} GET → No special headers POST → Add "X-Idempotency-Key" header PUT → Add "X-If-Match" header for concurrency ``` ### Use Case 3: Different Secrets Per Method ```text theme={"dark"} GET → Use caching credentials POST → Use write credentials + webhook secret ``` ### Use Case 4: Security Levels ```text theme={"dark"} GET → No signature required (public reads) POST/PUT/DELETE → Require HMAC signature (mutations) ``` ## How It Works ### Fallback System ```text theme={"dark"} 1. Check: Does this HTTP method have a method config? ↓ YES Use method-specific config ↓ NO Use global route config (fallback) ``` **Example:** ```text theme={"dark"} Route global config: inject_headers: {"Authorization": "Bearer {{secret:read_key}}"} POST method config: inject_headers: {"Authorization": "Bearer {{secret:write_key}}"} GET method config: (none - uses global) ``` **Result:** * GET requests → Uses read\_key (global) * POST requests → Uses write\_key (method config) * PUT requests → Uses read\_key (global, no method config) ## Create Method Config ### Step 1: Edit Your Route 1. Navigate to **Routes** 2. Click your route 3. Click **Edit** ### Step 2: Scroll to Method Configs Find the **Method-Specific Configuration** section ### Step 3: Add Method Config Click **Add Method Config** **Select HTTP Method:** ```text theme={"dark"} POST ``` **Inject Headers:** ```json theme={"dark"} { "Authorization": "Bearer {{secret:stripe_write_key}}", "X-Idempotency-Key": "{{var:idempotency_key}}" } ``` **Inject Body:** ```json theme={"dark"} { "api_key": "{{secret:stripe_write_key}}", "timestamp": "{{var:timestamp}}" } ``` **Override other settings** (optional): * Rate limit * Require signature * Allowed origins ### Step 4: Save Click **Save Changes** Now POST requests use different configuration! ## Example: Read vs Write Keys ### Scenario You have a REST API with: * **Read operations**: GET (low risk) * **Write operations**: POST, PUT, DELETE (high risk) You want different API keys for each. ### Setup **Create secrets:** 1. `api_read_key` → sk\_read\_abc123 2. `api_write_key` → sk\_write\_xyz789 **Route global config:** ```json theme={"dark"} { "inject_headers": { "Authorization": "Bearer {{secret:api_read_key}}" } } ``` *Default: Use read-only key* **POST method config:** ```json theme={"dark"} { "inject_headers": { "Authorization": "Bearer {{secret:api_write_key}}" } } ``` **PUT method config:** ```json theme={"dark"} { "inject_headers": { "Authorization": "Bearer {{secret:api_write_key}}" } } ``` **DELETE method config:** ```json theme={"dark"} { "inject_headers": { "Authorization": "Bearer {{secret:api_write_key}}", "X-Confirm-Delete": "true" } } ``` ### Result `$TOKEN` in the examples below is a KnoxCall OAuth access token (`kc_…`) — see [Authentication](/api-reference/authentication) for how to mint one. Legacy API keys still work on the data plane: send `-H "x-knoxcall-key: tk_live_…"` *instead of* the `Authorization` header (never both). ```bash theme={"dark"} # GET → Uses read_key curl https://a1b2c3d4.DunderMifflin.knoxcall.com/api/users \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: user-api" # Backend receives: Authorization: Bearer sk_read_abc123 # POST → Uses write_key curl -X POST https://a1b2c3d4.DunderMifflin.knoxcall.com/api/users \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: user-api" \ -d '{"name":"John"}' # Backend receives: Authorization: Bearer sk_write_xyz789 # DELETE → Uses write_key + confirm header curl -X DELETE https://a1b2c3d4.DunderMifflin.knoxcall.com/api/users/123 \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: user-api" # Backend receives: # Authorization: Bearer sk_write_xyz789 # X-Confirm-Delete: true ``` ## Example: Idempotency for POST ### Scenario Your API requires idempotency keys for POST requests to prevent duplicate processing. ### Setup **POST method config only:** ```json theme={"dark"} { "inject_headers": { "Authorization": "Bearer {{secret:api_key}}", "X-Idempotency-Key": "{{var:idempotency_key}}" } } ``` ### Usage Client includes idempotency key in query param: ```bash theme={"dark"} curl -X POST "https://a1b2c3d4.DunderMifflin.knoxcall.com/api/charge?idempotency_key=abc-123-xyz" \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: payment-api" \ -d '{"amount": 1000}' ``` KnoxCall extracts `idempotency_key` from query and injects as header: ```text theme={"dark"} POST /api/charge Authorization: Bearer sk_live_... X-Idempotency-Key: abc-123-xyz ``` Backend receives idempotency key in header (standard pattern). ## Example: Signature Only for Mutations ### Scenario You want to require HMAC signatures for POST/PUT/DELETE but not GET (performance). ### Setup **Global route config:** ```text theme={"dark"} require_signature: false ``` **POST method config:** ```text theme={"dark"} require_signature: true ``` **PUT method config:** ```text theme={"dark"} require_signature: true ``` **DELETE method config:** ```text theme={"dark"} require_signature: true ``` ### Result ```bash theme={"dark"} # GET → No signature required (fast) curl https://a1b2c3d4.DunderMifflin.knoxcall.com/api/users \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: user-api" # ✓ Works # POST → Signature required curl -X POST https://a1b2c3d4.DunderMifflin.knoxcall.com/api/users \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: user-api" \ -d '{"name":"John"}' # ✗ Fails: Missing signature # POST with signature → Works curl -X POST https://a1b2c3d4.DunderMifflin.knoxcall.com/api/users \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: user-api" \ -H "x-knoxcall-signature: t=1234567890,v2=abc123..." \ -d '{"name":"John"}' # ✓ Works ``` When you authenticate with an OAuth `kc_` Bearer token, the signature is keyed with **that access token**, and it must use the **`v2`** scheme, which binds the HTTP method and path as well as the body. Which secret signs a request depends on the credential you authenticate with - see [Request Signing](/security/request-signing#which-secret-signs-the-request). ## Advanced: Method-Specific Rate Limits ### Scenario Allow many GET requests (reads) but limit POST requests (writes). ### Setup **Global route:** ```text theme={"dark"} rate_limit_requests: 1000 rate_limit_window_sec: 60 ``` *1000 requests per minute for GET* **POST method config:** ```text theme={"dark"} rate_limit_requests: 100 rate_limit_window_sec: 60 ``` *Only 100 POST requests per minute* ### Result * GET: 1000/min allowed * POST: 100/min allowed * Other methods: 1000/min (global) ## Variables Available in Templates ### Query Parameters: `{{var:param_name}}` ```bash theme={"dark"} # Request GET /api/users?filter=active&sort=name # Template { "X-Filter": "{{var:filter}}", "X-Sort": "{{var:sort}}" } # Backend receives X-Filter: active X-Sort: name ``` ### Secrets: `{{secret:name}}` ```json theme={"dark"} { "Authorization": "Bearer {{secret:api_key}}" } ``` ### Environment Variables Can reference in templates for dynamic values. ## Viewing Method Configs ### In Route Details 1. Navigate to **Routes** 2. Click your route 3. See **Method Configs** section 4. Shows all configured methods with icons: * 🟢 GET * 🔵 POST * 🟡 PUT * 🔴 DELETE * ⚫ PATCH * 🟣 OPTIONS * ⚪ HEAD ### In Logs Request logs show which config was used: ```text theme={"dark"} Method: POST Config Used: POST method config Headers Injected: Authorization, X-Idempotency-Key ``` Or: ```text theme={"dark"} Method: GET Config Used: Global route config (no method override) Headers Injected: Authorization ``` ## Best Practices ### 1. Use Global as Default Configure global headers/body for the most common case (usually GET). Method configs only for exceptions. ### 2. Principle of Least Privilege ```text theme={"dark"} GET → Read-only credentials POST/PUT → Write credentials DELETE → Admin credentials with confirmation ``` Give each method minimum necessary permissions. ### 3. Document Your Method Configs Use route description to note: ```text theme={"dark"} GET: Uses read_key (public) POST: Uses write_key + idempotency DELETE: Requires admin_key + signature ``` Helps team understand configuration. ### 4. Test Each Method Don't assume method configs work without testing: ```bash theme={"dark"} # Test GET curl -X GET ... # Test POST curl -X POST ... # Test DELETE curl -X DELETE ... ``` Verify correct headers/body injected for each. ### 5. Monitor Per-Method Metrics In Analytics, filter by HTTP method: * GET success rate * POST latency * DELETE error rate Identify method-specific issues. ## Common Patterns ### REST API Standard ```text theme={"dark"} GET → read_key POST → write_key PUT → write_key PATCH → write_key DELETE → admin_key ``` ### Webhook Receiver ```text theme={"dark"} POST → Validate webhook signature → Inject processing credentials GET → Return 405 Method Not Allowed ``` ### File Upload API ```text theme={"dark"} POST → Large body size limit (100MB) → Inject S3 credentials GET → Normal limit (10MB) → Inject read-only credentials ``` ## Troubleshooting ### Method Config Not Applied **Symptoms:** Request uses global config instead of method config **Check:** 1. Method name matches the request method (matching is case-insensitive — both the config method and request method are upper-cased before comparison, so `POST`, `post`, and `Post` all match) 2. Method config is saved 3. Route is enabled 4. Check logs to see which config was used ### Wrong Headers Injected **Debug:** 1. Check request logs 2. See "Config Used" field 3. Verify method config has correct headers 4. Check for typos in secret names ### Variables Not Replaced **Symptoms:** Backend receives literal `{{var:param}}` **Causes:** * Query parameter name doesn't match * Typo in template: `{{var:paramter}}` instead of `{{var:parameter}}` * Query parameter not provided in request **Fix:** 1. Check query parameter name in request 2. Match exactly in template 3. Verify in logs ## Related Concepts * **Global Route Config**: Default configuration used when no method override * **Secrets**: Encrypted credentials injected via `{{secret:name}}` * **Variables**: Query parameters accessible via `{{var:name}}` * **Rate Limiting**: Can be different per method * **Request Signing**: Can be required only for certain methods ## Next Steps Use secrets in method configs Different limits per method Require signatures for mutations Test each method configuration *** * **Level**: intermediate * **Time**: 10 minutes `routing`, `configuration`, `http-methods`, `rest-api` # Rate Limiting Source: https://docs.knoxcall.com/advanced/rate-limiting Protect your APIs from abuse with flexible rate limiting. Configure per-client limits, burst protection, and DDoS mitigation. # Rate Limiting Protect your backend APIs from abuse, excessive load, and DDoS attacks with KnoxCall's intelligent rate limiting system. ## What is Rate Limiting? Rate limiting controls how many requests a client can make within a specific time window. It prevents: * ❌ **API abuse** - Malicious users making excessive requests * ❌ **Accidental overload** - Buggy code creating infinite loops * ❌ **DDoS attacks** - Distributed denial of service attempts * ❌ **Cost overruns** - Preventing excessive API usage costs ## How Rate Limiting Works ```text theme={"dark"} Client makes request ↓ Check request count for this client ↓ Within limit? → ✅ Forward request → Increment counter ↓ Exceeded limit? → ❌ Return 429 Too Many Requests ``` **Rate limit counters reset** based on your configured window: * Per minute: Resets every 60 seconds * Per hour: Resets every hour * Per day: Resets at midnight UTC ## Configuration Levels KnoxCall supports rate limiting at multiple levels (applied in order): ### 1. Tenant-Level Limits (Account-Wide) **First checkpoint:** Total requests across entire tenant ```text theme={"dark"} Tenant: Acme Corp (Pro Plan) Limit: 5,000 requests/minute (all routes + clients combined) ALL traffic through KnoxCall cannot exceed 5k req/min ``` **How it works:** * Token bucket algorithm (allows short bursts) * Refills continuously at your plan's per-minute rate * The bucket holds **ten seconds** of that rate — the most you can send at once * Applied before route or client limits * Automatic based on subscription plan **Plan Tiers:** ```text theme={"dark"} Sustained rate Burst (at once) Free: 100 requests/minute 17 Standard: 1,000 requests/minute 167 Pro: 5,000 requests/minute 834 Enterprise: 10,000 requests/minute 1,667 (customizable) ``` The burst is ten seconds of the sustained rate, rounded up. Your per-minute entitlement is the full sustained rate: sent evenly, every request of it is admitted. What the bucket refuses is sending much of a minute's quota in the same instant. **Use case:** Platform-wide protection, billing enforcement, abuse prevention **Token bucket behavior:** ```text theme={"dark"} Pro plan Bucket capacity: 834 tokens (requests) — ten seconds of the rate Refill rate: 5,000 tokens/minute (~83 per second) Example: - 0:00:00 → 834 tokens available - 0:00:00 → Receive a burst of 1,000 requests ✅ 834 admitted, ❌ 166 rejected (429) - 0:00:05 → 416 tokens refilled (416 available) - 0:00:10 → bucket full again (834) - From then on, 83 requests every second are admitted indefinitely — 5,000 a minute — because each second refills what the last one spent. ``` **Benefits:** * ✅ Allows short traffic bursts (better UX than strict limits) * ✅ Still protects against sustained abuse * ✅ A burst can never spend a whole minute of quota at once * ✅ Industry standard for API gateways Until October 2026 the bucket held a full minute of your rate, so an Enterprise tenant could send 10,000 requests in one second. It now holds ten seconds. Your per-minute rate did not change. If you need a larger burst, ask us — a per-tenant override can state one. ### 2. Route-Level Limits **Second checkpoint:** Limits per route ```text theme={"dark"} Route: stripe-webhooks Limit: 10,000 requests/hour ALL clients using this route combined cannot exceed 10k req/hour ``` **Use case:** Protect specific backend APIs from overload ### 3. Client-Level Limits **Third checkpoint:** Limits per client ```text theme={"dark"} Client: mobile-app-ios Limit: 1,000 requests/hour This specific client cannot exceed 1k req/hour across all routes ``` **Use case:** Fair usage per client/application, prevent single client from monopolizing resources ### 4. Method-Specific Limits **Fourth checkpoint:** Different limits per HTTP method ```text theme={"dark"} GET requests: 5,000/hour POST requests: 1,000/hour DELETE requests: 100/hour ``` **Use case:** Restrict write operations more than reads ### How Limits Stack Request passes through ALL levels in order: ```text theme={"dark"} 1. Tenant limit: 5,000/min, bursts of 834 → ✅ Pass (tokens left in the bucket) ↓ 2. Route limit: 1,000/hour → ✅ Pass (500 used) ↓ 3. Client limit: 500/hour → ✅ Pass (200 used) ↓ 4. Method limit (POST): 100/hour → ✅ Pass (50 used) ↓ 5. Request forwarded to backend API ``` If ANY limit exceeded → `429 Too Many Requests` ## Setting Up Rate Limits ### Route-Level Rate Limiting 1. Navigate to **Routes** → Select your route 2. Scroll to **Rate Limiting** section 3. Toggle **Enable Rate Limiting** to ON 4. Configure limits: **Request Limit:** ```text theme={"dark"} 1000 ``` **Time Window:** ```text theme={"dark"} hour ``` Options: `minute`, `hour`, `day` **Burst Allowance (Optional):** ```text theme={"dark"} 100 ``` Allows temporary spikes above the base limit. 5. Click **Save** ### Client-Level Rate Limiting 1. Navigate to **Clients** → Select your client 2. Scroll to **Rate Limiting** section 3. Configure limits: **Per-Client Limit:** ```text theme={"dark"} 500 requests/hour ``` **Applies to:** All routes this client can access ### Method-Specific Rate Limiting 1. Edit your route 2. Go to **Method Configurations** tab 3. For each HTTP method, set individual limits: ```yaml theme={"dark"} GET: Rate Limit: 5000/hour POST: Rate Limit: 1000/hour DELETE: Rate Limit: 100/hour ``` ## Rate Limit Response When a client exceeds the limit, they receive: **HTTP Status:** ```text theme={"dark"} 429 Too Many Requests ``` **Response Headers** (the tenant limit — `X-RateLimit-Layer` says which limit refused you): ```http theme={"dark"} X-RateLimit-Limit: 1000 X-RateLimit-Burst: 167 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1737112210000 X-RateLimit-Layer: tenant Retry-After: 10 ``` **Response Body:** ```json theme={"dark"} { "error": "Rate limit exceeded", "message": "Tenant rate limit exceeded. Limit: 1000 requests per minute, in bursts of at most 167.", "limit": 1000, "burst": 167, "remaining": 0, "resetAt": "2025-01-17T11:10:10.000Z", "retryAfter": 10, "layer": "tenant" } ``` For the tenant limit, `limit` is your plan's **per-minute** rate and `burst` is the most you can send **at once**. A refusal while you are under your per-minute rate means the burst ran out: spread the requests out, or wait `Retry-After` seconds — never more than about ten, because that is how long the bucket takes to refill from empty. ## Checking Rate Limit Status Clients can check their current status via response headers on **every request**. For the tenant limit they are: ```http theme={"dark"} HTTP/1.1 200 OK X-RateLimit-Tenant-Limit: 1000 X-RateLimit-Tenant-Burst: 167 X-RateLimit-Tenant-Remaining: 142 X-RateLimit-Tenant-Reset: 1737112203000 ``` **Headers:** * `X-RateLimit-Tenant-Limit`: your plan's sustained rate, in requests **per minute** * `X-RateLimit-Tenant-Burst`: the most requests admitted at once (the bucket size — ten seconds of the rate) * `X-RateLimit-Tenant-Remaining`: requests you can send **right now** — never more than the burst * `X-RateLimit-Tenant-Reset`: when the bucket is full again, as epoch **milliseconds** The same four numbers are in `X-Knox-RateLimit-Tenant` as `limit=…, remaining=…, reset=…, burst=…` — see [Response metadata](/api-reference/response-metadata). A route with its own rate limit switched on also sends `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` for that route's window. ### Monitoring Tenant-Level Limits **Dashboard view:** Navigate to **Account** → **Usage** to see your tenant-wide rate limit status: ```text theme={"dark"} Your Plan: Pro (5,000 requests/minute) Current Usage: ┌────────────────────────────────┐ │████████░░░░░░░░░░░░░░░░░░░░░░│ 32% (1,600/5,000) └────────────────────────────────┘ Recent Activity (last 60 seconds): 0:00 - 0:10: 400 requests 0:10 - 0:20: 350 requests 0:20 - 0:30: 420 requests 0:30 - 0:40: 230 requests 0:40 - 0:50: 150 requests 0:50 - 1:00: 50 requests Token Bucket Status: Available tokens: 400 / 834 (the bucket is ten seconds of the rate) Refill rate: 83.3 tokens/second Time to full: 5.2 seconds ``` **API endpoint:** Current tenant-level rate limit usage is visible in `GET /v1/account/usage` — see the [Account API reference](/api-reference/account). **When to upgrade:** * ⚠️ Consistently using >80% of tokens * ⚠️ Seeing 429 errors in logs * ⚠️ Traffic growing month-over-month * ⚠️ Planning marketing campaign or launch **Upgrade options:** ```text theme={"dark"} Free → Standard: 10x increase (100 → 1,000 req/min) Standard → Pro: 5x increase (1,000 → 5,000 req/min) Pro → Enterprise: 2x increase + custom limits (5,000 → 10,000+) ``` ## Burst Protection Handle temporary traffic spikes without blocking legitimate users: **Configuration:** ```text theme={"dark"} Base Limit: 1,000 requests/hour Burst Allowance: 200 requests ``` **How it works:** * Client can make up to **1,200 requests** in a short burst * After burst, limited to **1,000 requests/hour** average * Prevents legitimate spikes from being blocked **Example:** ```text theme={"dark"} Minute 1: 200 requests ✅ (burst) Minute 2: 200 requests ✅ (burst) Minute 3: 200 requests ✅ (burst) Minute 4: 200 requests ✅ (burst) Minute 5: 200 requests ✅ (burst) Minute 6: 200 requests ❌ (burst depleted) Remaining hour: ~17 requests/minute average ``` ## Advanced Strategies ### Per-User Rate Limiting Use different limits based on user tiers: ```text theme={"dark"} Free Tier Client: Limit: 100 requests/hour Pro Tier Client: Limit: 1,000 requests/hour Enterprise Client: Limit: 10,000 requests/hour ``` Create separate clients for each tier. ### Geographic Rate Limiting Combine with IP whitelisting: ```text theme={"dark"} US Region: 5,000 requests/hour EU Region: 3,000 requests/hour APAC Region: 2,000 requests/hour ``` Create region-specific clients with different limits. ### Time-Based Rate Limiting Different limits for peak vs off-peak: **Peak Hours** (9 AM - 5 PM): * Limit: 500 requests/hour **Off-Peak**: * Limit: 2,000 requests/hour This requires creating separate routes or using API-based dynamic configuration. ## Rate Limit Monitoring ### View Rate Limit Events 1. Navigate to **Logs** → **API Logs** 2. Filter by status code: `429` 3. See which clients are hitting limits ### Set Up Alerts Get notified when clients hit rate limits: 1. Navigate to **Alerts** → **Add Alert** 2. Select **Rate Limit Exceeded** 3. Configure: ```text theme={"dark"} Alert Type: Rate Limit Exceeded Threshold: 10 violations/hour Channels: Email, Slack ``` ### Analytics Dashboard Monitor rate limit metrics: * **Hit rate**: % of requests that are rate-limited * **Top offenders**: Clients hitting limits most often * **Trend analysis**: Rate limit violations over time ## Best Practices ### 1. Start Conservative Begin with strict limits and relax based on usage: ```text theme={"dark"} Initial: 100 requests/hour After monitoring: 500 requests/hour Production stable: 1,000 requests/hour ``` ### 2. Use Tiered Limits Different limits for different client types: ```text theme={"dark"} Public API: 100/hour Partner API: 1,000/hour Internal Services: 10,000/hour ``` ### 3. Enable Burst Protection Allow temporary spikes: ```text theme={"dark"} Base: 1,000/hour Burst: +20% (1,200 total) ``` ### 4. Monitor and Adjust * Check rate limit logs weekly * Adjust limits based on legitimate usage * Set alerts for unusual patterns ### 5. Communicate Limits Document your rate limits for API consumers: ```markdown theme={"dark"} ## Rate Limits - **Free Tier**: 100 requests/hour - **Pro Tier**: 1,000 requests/hour - **Enterprise**: Custom limits Headers included in every response. ``` ## Common Configurations ### Webhook Endpoint ```text theme={"dark"} Limit: 10,000 requests/hour Burst: 500 requests Reason: Webhooks can spike during events ``` ### Public API ```text theme={"dark"} Limit: 100 requests/hour per API key Burst: 20 requests Reason: Prevent abuse of public endpoints ``` ### Internal Microservices ```text theme={"dark"} Limit: 50,000 requests/hour Burst: 5,000 requests Reason: High-traffic internal communication ``` ### Payment Processing ```text theme={"dark"} POST /payments: 10 requests/minute GET /payments: 100 requests/minute Reason: Prevent duplicate payment charges ``` ## Handling Rate Limits (Client-Side) ### Exponential Backoff When receiving 429, implement retry logic: ```javascript theme={"dark"} async function makeRequestWithRetry(url, maxRetries = 3) { for (let i = 0; i < maxRetries; i++) { const response = await fetch(url); if (response.status === 429) { const retryAfter = response.headers.get('Retry-After'); const waitTime = retryAfter ? parseInt(retryAfter) * 1000 : Math.pow(2, i) * 1000; console.log(`Rate limited. Waiting ${waitTime}ms before retry...`); await new Promise(resolve => setTimeout(resolve, waitTime)); continue; } return response; } throw new Error('Max retries exceeded'); } ``` ### Check Headers Proactively ```javascript theme={"dark"} const response = await fetch(url); const limit = response.headers.get('X-RateLimit-Limit'); const remaining = response.headers.get('X-RateLimit-Remaining'); const reset = response.headers.get('X-RateLimit-Reset'); if (remaining < 10) { console.warn(`Low on rate limit: ${remaining}/${limit} remaining`); } ``` ### Request Queuing Prevent hitting limits by queuing requests: ```javascript theme={"dark"} class RateLimitedQueue { constructor(maxRequestsPerHour) { this.maxRequests = maxRequestsPerHour; this.queue = []; this.requestTimestamps = []; } async enqueue(requestFn) { // Remove timestamps older than 1 hour const oneHourAgo = Date.now() - 3600000; this.requestTimestamps = this.requestTimestamps.filter(t => t > oneHourAgo); // Wait if at limit while (this.requestTimestamps.length >= this.maxRequests) { const oldestRequest = this.requestTimestamps[0]; const waitTime = oldestRequest + 3600000 - Date.now(); await new Promise(resolve => setTimeout(resolve, waitTime)); this.requestTimestamps.shift(); } this.requestTimestamps.push(Date.now()); return await requestFn(); } } ``` ## Troubleshooting ### High False Positive Rate **Problem:** Legitimate users hitting limits **Solutions:** * Increase burst allowance * Raise base limits * Use per-user instead of per-IP limits ### DDoS Still Getting Through **Problem:** Rate limits not preventing attacks **Solutions:** * Lower limits for unknown clients * Enable request signing * Use IP-based blocking * Contact support for enterprise DDoS protection ### Inconsistent Limit Enforcement **Problem:** Some requests bypass rate limits **Check:** * Rate limits enabled on all routes * No conflicting client configurations * Limits applied at correct level (route vs client) ## Next Steps Add cryptographic signatures for extra security Get notified of rate limit violations Monitor rate limit metrics Set up per-client limits *** * **Level**: intermediate * **Time**: 15 minutes `rate-limiting`, `security`, `ddos`, `api-protection` # Visual Body Editor Source: https://docs.knoxcall.com/advanced/visual-body-editor Use the drag-and-drop body editor to visually configure JSON injection without writing templates. Perfect for complex nested payloads. # Visual Body Editor Configure complex JSON body injection visually with drag-and-drop. No template syntax required. ## What is the Visual Body Editor? The **Visual Body Editor** is a drag-and-drop interface for injecting values (like secrets) into specific locations in your JSON request body. **Traditional way (manual templates):** ```json theme={"dark"} { "api_key": "{{secret:stripe_key}}", "user": { "id": 123, "token": "{{secret:user_token}}" } } ``` *Manual JSON editing with template syntax* **Visual way:** 1. Capture or paste an example JSON payload 2. Drag injection rules onto specific fields 3. KnoxCall builds the template automatically Perfect for complex nested payloads! ## When to Use This Feature ### Use Visual Editor When: * ✅ You have complex nested JSON structures * ✅ You need to inject multiple secrets at different depths * ✅ You want to see the exact structure visually * ✅ You're not comfortable with JSON template syntax * ✅ You need to inject values into deeply nested objects ### Use Manual Template When: * ⚠️ Simple flat JSON payloads (1-2 fields) * ⚠️ You prefer writing JSON directly * ⚠️ Dynamic payloads that vary by request ## How It Works ```text theme={"dark"} 1. Capture or paste example JSON payload ↓ 2. KnoxCall parses structure and identifies all paths ↓ 3. Create injection rules (key-value pairs) ↓ 4. Drag rules onto specific JSON fields ↓ 5. KnoxCall generates template automatically ↓ 6. Backend receives requests with injected values ``` ## Step-by-Step Guide ### Step 1: Open Route Configuration 1. Navigate to **Routes** 2. Click your route 3. Go to **Configuration** tab (or **Method Configs** for method-specific body) 4. Scroll to **Body Injection** section ### Step 2: Capture or Paste Payload You have two options: #### Option A: Capture Live Request (Recommended) Best for APIs you can actually call. 1. Click **Capture Payload** 2. Choose **Live Request** 3. Copy the unique capture URL 4. Send a POST request to that URL with example payload: ```bash theme={"dark"} curl -X POST "https://a1b2c3d4.DunderMifflin.knoxcall.com/capture/abc123..." \ -H "Content-Type: application/json" \ -d '{ "payment": { "amount": 1000, "currency": "usd", "source": "tok_visa" }, "customer": { "email": "customer@example.com", "name": "John Doe" } }' ``` 5. KnoxCall automatically captures the structure **Who can retrieve a live capture.** The request you send to the capture URL is captured traffic, so retrieving it is a reveal: only the Owner and Admins can, and each retrieval asks for a fresh second factor (an authenticator code, a passkey or a security key) and is recorded in the audit log before the request comes back. An Editor's capture is refused when the request arrives (only the person holding the capture link can retrieve it, and a capture expires after 5 minutes), so an Editor uses **Paste JSON** below, which reveals nothing and works for every role that can edit the route. The sample saved from a capture is behind the same rule: see **What the editor shows** below. #### Option B: Paste JSON (Faster) Best when you already have example JSON. 1. Click **Capture Payload** 2. Choose **Paste JSON** 3. Paste your example JSON: ```json theme={"dark"} { "payment": { "amount": 1000, "currency": "usd", "source": "tok_visa", "api_key": "" }, "customer": { "email": "customer@example.com", "name": "John Doe" } } ``` 4. Click **Submit Payload** **Tip:** Use realistic example data. The structure you provide becomes your template. Any fields you add later won't have drop zones unless you recapture. ### What the editor shows: the structure, not the values A saved sample is usually a real request, so the dashboard shows it as its **structure**: every key and every level of nesting, with each value replaced by its type — `""`, `""` or `""` (`null` stays `null`, and a list keeps each item, shown the same way). That is everything the editor needs: every field is still a drop zone, and rules are assigned to paths exactly as before. This is the same for every role, and for the person who pasted the sample — saving stores what you sent, and reading it back shows its structure. ```text theme={"dark"} Payload Structure: payment amount: currency: source: customer email: name: ``` **Seeing the values.** The Owner and Admins see an eye button on the **Body** card. Clicking it reveals the saved values for the environment (and, in method-specific mode, the method) on screen: it asks for an optional reason and a fresh second factor (an authenticator code, a passkey or a security key), and is recorded in the audit log before the values are shown. Values under sensitive-looking field names (authorization headers, API keys, card numbers) stay masked even then. The values are hidden again when you switch environment or method, or save. Editors and Viewers cannot reveal them, and the Management API (`/v1`) never returns them. The eye button appears only for a sample that is saved where you are looking. A method with no sample of its own shows the route's default structure, and an environment with no sample of its own shows the base environment's; there are no values to reveal for either, so the card says so instead of offering the button. ### Step 3: Create Injection Rules Injection rules are key-value pairs you'll drag onto your JSON structure. 1. Click **+ Add Injection Rule** 2. Enter **Key** (field name to inject): ``` api_key ``` 3. Enter **Value** (what to inject): ``` {{secret:stripe_prod_key}} ``` 4. Or click **🔑 Secrets** button to pick from dropdown 5. Click **Add Rule** **Example rules:** | Key | Value | Purpose | | - | - | - | | api\_key | `{{secret:stripe_key}}` | Stripe API key | | auth\_token | `{{secret:user_token}}` | User auth token | | webhook\_secret | `{{secret:webhook_secret}}` | Webhook signing | | customer\_id | `{{var:customer_id}}` | From query param | **Variables work too!** Use `{{var:param_name}}` to inject query parameters from the incoming request. ### Step 4: Drag Rules onto JSON Structure Now the fun part - visually place your injection rules. **The interface shows:** * **Top section:** List of injection rules (draggable) * **Bottom section:** Your JSON payload structure (drop zones) **To assign a rule:** 1. Click and hold an injection rule from the top 2. Drag it over the JSON structure below 3. Drop it onto a specific field **Drop indicators:** * **Blue line above** = Drop before this field * **Blue line below** = Drop after this field * **"DROP AS CHILD →"** badge = Drop inside nested object **Example:** ```text theme={"dark"} Injection Rules (top): ┌─────────────────────────────────────────┐ │ ⋮⋮ api_key: {{secret:stripe_key}} │ ← Drag this └─────────────────────────────────────────┘ Payload Structure (bottom): payment ← Drop zone amount: 1000 currency: usd [Drop here to inject as child] ⬇️ After drop: payment amount: 1000 currency: usd ✓ INJECTED api_key: {{secret:stripe_key}} ← Injected! ``` ### Step 5: Assign Nested Fields For nested objects, you can inject at any depth: **Example structure:** ```text theme={"dark"} user profile settings notifications email: true sms: false ``` **Drag rule onto `notifications` with "DROP AS CHILD":** ```text theme={"dark"} user profile settings notifications email: true sms: false ✓ INJECTED token: {{secret:notification_token}} ``` **Result JSON path:** `user.profile.settings.notifications.token` ### Step 6: Save Route Click **Save** or **Update Route** Your body injection template is now configured! ## Understanding Injection Rules ### Rule Components Each injection rule has three properties: **1. Key (Required)** * The field name to inject * Example: `api_key`, `token`, `customer_id` * Shows in the final JSON **2. Value (Required)** * The value to inject * Can be: * Secret: `{{secret:stripe_key}}` * Variable: `{{var:user_id}}` * Static: `"production"` * Mixed: `Bearer {{secret:token}}` **3. Path (Auto-assigned by drag-drop)** * Where in the JSON to inject * Example: `payment.details.api_key` * Automatically set when you drop the rule * Can be empty if not assigned yet ### Rule States **Unassigned (gray):** ```text theme={"dark"} ⋮⋮ api_key: {{secret:stripe_key}} Not assigned ⚠️ ``` *Rule exists but not placed in JSON structure* **Assigned (green):** ```text theme={"dark"} ⋮⋮ api_key: {{secret:stripe_key}} @ payment.api_key ✓ ``` *Rule assigned to `payment.api_key` path* **Unassigned rules are not injected!** Only rules with a path (assigned via drag-drop) are included in requests. ## Managing Rules ### Reorder Rules (Drag Within JSON) To move a rule to a different location: 1. Find the green **✓ INJECTED** box in your JSON structure 2. Drag it to a new location 3. Drop on a different field The rule updates to the new path automatically. ### Remove Rule Assignment To unassign a rule (without deleting it): 1. Click **Remove** button on the green **✓ INJECTED** box 2. Rule moves back to unassigned list (top section) 3. Can be reassigned later by dragging again ### Delete Rule Completely To permanently delete a rule: 1. Click **Remove** button in the **Injection Rules** list (top section) 2. Rule is deleted entirely 3. If it was assigned, it's removed from JSON structure too ### Edit Existing Rule Currently, rules cannot be edited after creation. To change: 1. Delete the existing rule 2. Create a new rule with updated values 3. Drag onto the same location ## Example Use Cases ### Use Case 1: Payment API with Nested Secrets **Goal:** Inject Stripe key and webhook secret at different depths **Example payload:** ```json theme={"dark"} { "payment": { "amount": 5000, "currency": "usd", "api_key": "" }, "webhook": { "url": "https://example.com/webhook", "secret": "" } } ``` **Injection rules:** | Key | Value | Path | | - | - | - | | api\_key | `{{secret:stripe_prod_key}}` | payment.api\_key | | secret | `{{secret:webhook_secret}}` | webhook.secret | **Result (what backend receives):** ```json theme={"dark"} { "payment": { "amount": 5000, "currency": "usd", "api_key": "sk_live_EXAMPLE_abc123xyz789..." }, "webhook": { "url": "https://example.com/webhook", "secret": "whsec_def456..." } } ``` ### Use Case 2: User Profile with Dynamic Variables **Goal:** Inject user-specific tokens from query params **Example payload:** ```json theme={"dark"} { "user": { "profile": { "email": "user@example.com" }, "auth": { "token": "" } } } ``` **Incoming request:** ```text theme={"dark"} GET /api/user?user_id=12345 ``` **Injection rules:** | Key | Value | Path | | - | - | - | | token | `{{secret:api_token}}` | user.auth.token | | id | `{{var:user_id}}` | user.id | **Result:** ```json theme={"dark"} { "user": { "id": "12345", "profile": { "email": "user@example.com" }, "auth": { "token": "bearer_abc123..." } } } ``` ### Use Case 3: Multi-Service Request **Goal:** Call multiple services with different API keys in one payload **Example payload:** ```json theme={"dark"} { "stripe": { "key": "", "action": "charge" }, "sendgrid": { "key": "", "action": "send" }, "twilio": { "key": "", "action": "sms" } } ``` **Injection rules:** | Key | Value | Path | | - | - | - | | key | `{{secret:stripe_key}}` | stripe.key | | key | `{{secret:sendgrid_key}}` | sendgrid.key | | key | `{{secret:twilio_key}}` | twilio.key | All secrets injected at different paths with the same key name! ## Editing Payload Structure ### Recapture Payload If your API payload structure changed: 1. Click **Recapture** button (next to payload structure) 2. Send new request or paste updated JSON 3. **⚠️ Warning:** Existing rule assignments are lost 4. Drag rules onto new structure again **Recapturing clears all rule assignments!** Save your rule list somewhere if you need to reference it. The rules themselves aren't deleted, but their path assignments are cleared. ### Edit Payload Manually For small tweaks: 1. Click **Edit** button 2. Modify JSON structure directly 3. Save 4. Reassign rules if paths changed The editor opens on what the page shows: the structure, or the values if an Owner or Admin revealed them first. Saving it **unchanged** keeps the stored values (so changing only the rules loses nothing). Saving an **edited** structure stores what you typed — so to keep the values while you edit, reveal them first. Copying one method's configuration to another, or syncing an environment's configuration from another environment, copies the structure, not the values (a target sample with the same structure keeps its own values); duplicating an environment copies the values too. ### Delete Payload Structure To start over: 1. Click **Delete** button 2. Confirm deletion 3. All rule assignments cleared 4. Can capture new payload structure ## Advanced Features ### Nested Object Injection Drop rules **into** nested objects: **Drag rule to right side of parent:** ```text theme={"dark"} user ← Hover here email: test@example.com [DROP AS CHILD →] appears ``` **Result:** ```text theme={"dark"} user email: test@example.com ✓ INJECTED token: {{secret:token}} ← Added as child ``` ### Multiple Rules on Same Level You can inject multiple fields at the same nesting level: ```text theme={"dark"} payment amount: 1000 ✓ INJECTED api_key: {{secret:stripe_key}} ✓ INJECTED merchant_id: {{secret:merchant_id}} ✓ INJECTED webhook_secret: {{secret:webhook_secret}} ``` All three injected as children of `payment`. ### Parent Path Requirements **Important:** When injecting into nested paths, the parent objects **must exist** in the incoming request or be defined in your payload structure. **Example:** * Injection path: `user.profile.token` * Incoming request **must** have `user.profile` object (even if empty) * Otherwise injection fails silently The editor shows warnings for nested injections: ```text theme={"dark"} profile ✓ INJECTED token: {{secret:token}} ⚠️ Parent key "user" must exist in request ``` ## Combining with Method Configs You can use visual body editor **per HTTP method**: **GET method:** No body injection (GET has no body) **POST method:** * Payload: User creation structure * Inject: `write_api_key`, `create_token` **PUT method:** * Payload: User update structure * Inject: `update_api_key`, `version_token` **DELETE method:** * Payload: Deletion confirmation * Inject: `delete_token`, `admin_key` Each method gets its own visual body editor with separate: * Payload structure * Injection rules * Drop zone assignments See [Method-Specific Config](/advanced/method-specific-config) for details. ## Troubleshooting ### Issue: "Drag not working" **Causes:** * Browser compatibility (use Chrome/Edge/Firefox) * JavaScript disabled * Conflicting browser extensions **Fix:** * Try different browser * Disable ad blockers temporarily * Clear browser cache ### Issue: "Drop indicator not showing" **Symptoms:** Can't see blue line or "DROP AS CHILD" badge **Causes:** * Not dragging an actual rule * Dragging too fast * CSS rendering issue **Fix:** * Drag slowly and hover over fields * Wait for indicator to appear * Refresh page ### Issue: "Rule not injected in requests" **Symptoms:** Backend doesn't receive injected field **Debug:** 1. Check rule is assigned (shows green with path) 2. Check rule appears in JSON structure (green ✓ INJECTED box) 3. Test route with Route Tester 4. View request details in logs 5. Verify secret exists if using `{{secret:name}}` **Common cause:** Rule created but never dragged onto structure (unassigned). ### Issue: "Parent key must exist" warning **Symptoms:** Injecting into nested path like `user.profile.token` **Cause:** Incoming requests don't always have `user.profile` object **Fix:** 1. Ensure incoming requests include parent object: ```json theme={"dark"} { "user": { "profile": {} } } ``` 2. Or inject at root level instead of nested 3. Or use method configs to define base structure ### Issue: "Payload structure deleted my rules" **Symptoms:** Recaptured payload and rules disappeared **Actually:** Rules still exist but assignments cleared **Fix:** 1. Rules are in top **Injection Rules** list 2. Drag them onto new payload structure 3. Save route 4. To prevent: Don't recapture unnecessarily ## Best Practices ### 1. Use Realistic Example Payloads ✅ **Good:** ```json theme={"dark"} { "order": { "items": [{"id": 123, "qty": 2}], "total": 50.00, "currency": "USD" } } ``` *Real structure with actual data types* ❌ **Bad:** ```json theme={"dark"} { "order": {} } ``` *Empty - won't show nested fields* ### 2. Name Rules Descriptively ✅ **Good:** ```text theme={"dark"} stripe_api_key sendgrid_auth_token user_webhook_secret ``` ❌ **Bad:** ```text theme={"dark"} key1 secret token ``` ### 3. Test After Configuration Always test with Route Tester: 1. Configure visual body editor 2. Click **Test Route** 3. Send test request 4. Verify injected fields in **Request Details** section 5. Check backend received correct values ### 4. Document Your Structure In route description, note the payload structure: ```text theme={"dark"} Stripe Payments Route Body structure: - payment.api_key → Stripe production key - payment.webhook_secret → Webhook verification - customer.id → From query param user_id ``` Helps team members understand the configuration. ### 5. Use Variables for Dynamic Values Don't hardcode dynamic data: ❌ **Bad:** ```json theme={"dark"} { "user_id": "12345" } ``` ✅ **Good:** ```json theme={"dark"} { "user_id": "{{var:user_id}}" } ``` *Inject from query parameter* ### 6. Organize Rules by Service Group related rules: ```text theme={"dark"} Injection Rules: ├─ Stripe: │ ├─ stripe_api_key │ └─ stripe_webhook_secret ├─ SendGrid: │ └─ sendgrid_api_key └─ Internal: └─ app_secret ``` ## Limitations ### Arrays Not Fully Supported The editor shows arrays but doesn't let you inject **into** array items: **Structure:** ```json theme={"dark"} { "items": [ {"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"} ] } ``` **Can inject:** Root or `items` level **Cannot inject:** Specific array item like `items[0].name` **Workaround:** Use manual template editing for array injection. ### Max Nesting Depth: 10 Levels Payloads deeper than 10 levels may not render properly: ```json theme={"dark"} { "a": { "b": { "c": { "d": { "e": { "f": { "g": { "h": { "i": { "j": { "k": {} } } } } } } } } } } } ``` *Level 11 and beyond not supported* ### No Conditional Injection Rules are always injected. Cannot do: * "Only inject if query param exists" * "Inject different value based on environment" **Workaround:** Use multiple method configs or environment overrides. ### Large Payloads (>1000 fields) Very large JSON structures may slow down the editor. **Workaround:** Break into multiple routes or use manual template editing. ## Related Features * **Manual Body Templates**: Edit JSON directly without visual editor * **Method Configs**: Different body per HTTP method * **Secrets**: Encrypted credentials for injection * **Variables**: Inject query parameters with `{{var:name}}` ## Next Steps Different body per HTTP method Test your body injection Create secrets for injection Headers, transformations, and more *** * **Level**: intermediate * **Time**: 15 minutes `body-injection`, `visual-editor`, `drag-drop`, `json`, `configuration` # Budgets & FinOps Source: https://docs.knoxcall.com/ai-gateway/budgets-finops Per-agent USD budgets with warn/block enforcement, and per-call cost attribution by user, team, model, and agent — priced from a versioned pricebook that never records $0 silently. # Budgets & FinOps Every call through the gateway is **priced** and **attributed**, and agents can carry **budgets** that warn or hard-block before overspend. ## Pricing Cost is computed from a versioned **pricebook** shipped with KnoxCall (`packs/ai-gateway-pricebook/`), covering current Anthropic, OpenAI, and Gemini models with dated price rows. Two guarantees matter: * **Never silently \$0.** If a model isn't in the pricebook, the call is recorded with `cost_source = 'unpriced'` and surfaced through an `unpriced_request_count` — never booked as free. Provider inference falls back across providers rather than dropping to zero. * **No remote config fetch.** The pricebook is local — there is no run-time call to a third-party price feed. A weekly in-process check flags any observed-but-unpriced models via an alertable `ai_gateway.unpriced_model` audit event, so you update the manifest deliberately. Each usage row records `cost_source` (`pricebook` / `unpriced` / `cache_hit`). ### Embedding calls are metered too If an agent runs the [semantic cache](/ai-gateway/quickstart#response-caching) (`cache_mode: "semantic"`), every cache lookup embeds the request — which is a real, billable provider call. Those calls **are metered**: they get their own usage rows, they appear in your exports, and they count against the agent's budget and against a token's signed daily caps. They were not, until 2026-08-28. If you turned semantic caching on before then, expect your row count and your metered spend to rise: the money was always being spent, and only the accounting changed. A budget can now trip on cache traffic. One deliberate asymmetry: under a token carrying a signed `max_cost_per_day_usd`, an embedding on a model that is **not in the pricebook** is not purchased at all. Semantic matching degrades to exact matching rather than making a spend the cap has no way to count. Since 2026-09-08 that degrade is no longer silent: the request's usage row records it, and the Agent's **Budgets & cache** tab counts it and names the reason. ## Budgets Attach a budget to an agent with a daily and/or monthly USD cap and an **overage action**: | Action | Behavior at the cap | | - | - | | `block` (default) | Reject the call before it reaches the upstream — `HTTP 429` with `X-Knox-AI-Budget-Pct`. | | `warn` | Allow the call, set `X-Knox-AI-Budget-Warning` to the utilization percentage, record a warning. | | `fallback` | Keep serving, from a **cheaper agent** you nominate with `fallback_agent_id`. | A blocked call gets **HTTP 429**, not 402: ```json theme={"dark"} { "error": "budget_exceeded", "error_description": "Daily budget exceeded: $50.0031 ≥ $50" } ``` with `X-Knox-AI-Budget-Pct` alongside it. (Earlier revisions of this page said `402`. 429 is what the gateway has always sent — treat a budget block as a back-off signal, and reach for the same retry logic you use for a rate limit.) `X-Knox-AI-Budget-Pct` carries the current utilization whenever there is something to say — over the cap on a `warn` agent, or from **95%** of it on any agent. `X-Knox-AI-Budget-Warning` is set only by the `warn` action at the cap itself. A call comfortably inside its budget gets neither header, so treat their absence as "nothing to report", not as "no budget configured". Budgets are stored in a cross-worker store (Redis when enabled) so the cap holds across all workers, not per-process. **`fallback` is enforced.** Earlier revisions of this page said it was not, and that was true until 2026-08-25: the handler branched only on `block` and `warn`, so an agent set to `fallback` proceeded on its expensive model at full price. It now serves the request from the agent named by `fallback_agent_id` — see [Falling back to a cheaper agent](#falling-back-to-a-cheaper-agent) for what is and is not swapped. **A budget cannot be enforced if its counter is unreadable.** The spend counter lives in a cross-worker store; if that store is unavailable the gateway cannot know how much has been spent. It sets `X-Knox-AI-Budget-Unmetered: 1`, and then: * on a **`block`** agent it refuses with `503 budget_unenforceable` rather than serve a request it cannot count; * on **`warn`** and **`fallback`** agents it serves the request, unmetered, and suppresses `X-Knox-AI-Budget-Pct` — a percentage computed from a frozen counter would read `0.0%`, which is the lie in header form. Treat `X-Knox-AI-Budget-Unmetered` as "this request was not counted against your cap". ### Failover chains (a different kind of fallback) Not to be confused with the budget action above, and this one **is** implemented. An agent can carry an ordered list of `fallback_route_ids`. When the primary upstream answers **5xx** (or the connection fails), the gateway tries each fallback route in order and serves the first non-5xx response. Each hop is a real, separately-authorised call, not a retry of the same one: * the fallback route resolves **its own** credential, so provider A's key is never sent to provider B, and provider B's linked-secret domain lock is checked; * a fallback the presenting token's scope does not cover is **skipped**, so a 5xx cannot become a way to reach an upstream the token was never granted; * the fallback is loaded in the **same environment** as the primary, so a `kp_test_` token cannot reach a Live route by way of a failure; * the usage row records the route that actually served, so cost lands against the provider that did the work. A 4xx from the primary is returned as-is: failover is for a provider that is down, not for one that refused you. ### Falling back to a cheaper agent Set `budget_overage_action: "fallback"` and `fallback_agent_id` to another agent of yours. Past the cap, the request is served using **that agent's upstream route and default model** — the two things that determine cost — and the response carries `X-Knox-AI-Budget-Fallback: ` plus `X-Knox-AI-Budget-Fallback-Model` so your client can see which agent answered. **Your policies do not change.** The firewall verdict, the PII stack and the tool allowlist of the agent you addressed stay in force; the fallback agent supplies only the route and the model. If it supplied its policies too, spending your own budget out would be a way to reach a laxer configuration — a control anyone could switch off with a credit card. The fallback route must be in the same data space (Live or Test) and, if the addressed agent is [region-pinned](/ai-gateway/data-residency), in the same region. A fallback that fails either check is not used. If the fallback is configured but unusable — the agent was deleted, paused, has no primary route, or points at itself — the request is served on the **original** route and the response says so with `X-Knox-AI-Budget-Fallback: unavailable`. It is never silently served at full price. ### Caps carried by the token itself An agent budget is set by you, the operator, on the agent. A **capability scope** can additionally carry `max_cost_per_day_usd` and `max_tokens_per_day` on one *token*, so a single credential can be confined below the agent's own cap. These are HMAC-signed fields of the token — editing them requires the tenant master key — and they are enforced on every `/v1/ai` call before the upstream is dialled. Over a token cap the gateway answers **`HTTP 429`** with: ```json theme={"dark"} { "error": "token_scope_limit_exceeded", "error_description": "This token's daily cost cap is spent: 25.0031 of 25." } ``` Four related refusals exist so a cap can never be silently skipped rather than enforced: | Code | Status | Meaning | | - | - | - | | `token_scope_limit_exceeded` | 429 | The token's own daily cost or token cap is spent. | | `token_scope_malformed` | 403 | The scope carries a limit that is not a usable number. Re-mint. | | `token_scope_unattributable` | 403 | The call cannot be attributed to the token, so its cap cannot be counted. | | `token_scope_unenforceable` | 503 | The counter or the pricebook needed to enforce the cap is unavailable. | | `token_scope_unpriced_model` | 403 | The model is not in the pricebook, so a cost cap could not be applied to it. | **There is no API today that mints a token with these fields set.** Both mint endpoints — `POST /v1/ai-gateway/agents/{agentId}/tokens` and the admin twin — deliberately stamp an **empty** scope, because neither offers a parameter to set one and inventing a restriction the operator never asked for is worse than no restriction at all. The one path that produces a scoped token today is an [OIDC binding](/ai-gateway/tokens-and-dpop#oidc-workload-federation-ci-without-static-secrets), whose scope you author on the binding — and which now **refuses** a scope that narrows nothing. An explicit `scope` parameter on mint is planned and not shipped. ## Attribution Every call is attributed so you can build showback/chargeback: * **User** — pass `X-KC-User` on the request, set to the person's key in your workspace directory: * for someone your identity provider provisions over **SCIM**, their SCIM `externalId` — or their `userName`, if your IdP sends no `externalId`; * for any other member, their KnoxCall sign-in email. The value is matched exactly as the directory holds it. An id with no directory entry does not block the call; the response carries `X-Knox-AI-Attribution: user_unregistered` and the usage row is left unattributed. The same happens once a person's membership ends — deprovisioned by your IdP or removed from the team page — so spend is never billed to someone who has left. Their earlier usage keeps its attribution. * **Team** — the directory team for that user, unless the request names another with `X-KC-Team`. * **Model** and **agent** — recorded automatically. ### The `X-KC-Team` override `X-KC-Team` bills one call to a cost centre other than the user's default team — a contractor working across projects, a batch job charged to whoever asked for it. It is a client header, so the gateway resolves it before it can reach a ledger: * it must be a **UUID**, and * it must name a **group your workspace has declared**. A header that fails either check does not fail the request. The directory team stands, the response carries `X-Knox-AI-Attribution: team_malformed` or `team_unknown`, and the claimed value is logged. A request that sends `X-KC-Team` with no `X-KC-User` is attributed to the team alone — a service account or CI job has a cost centre and no employee behind it. **How people get into the directory.** SCIM provisioning (Enterprise) adds each person the moment your IdP creates them — the directory entry and the workspace membership are written together, and a later rename or `externalId` change in the IdP moves the same entry rather than creating a second one. A member who is not provisioned over SCIM gets an entry the first time they connect their own account to an MCP server or approve a device, keyed on their KnoxCall email. If they later change that email, the same entry moves to the new address and the old address stops identifying them — at once in every workspace they belong to, and in a workspace they have left, at the moment they rejoin it. If the new address is already another person's directory key in your workspace, their entry stops answering to either address — the old one comes back `user_unregistered` and the new one keeps naming the person who holds it — until that key changes and they next connect an MCP account or approve a device. Signing in with SAML SSO does not by itself create one. There is not yet a way to add directory entries for people who are not workspace members (your own application's end users); until there is, attribute those calls by **agent-per-team** and group spend by agent. `X-KC-Team` resolves only against groups your workspace has declared, and there is not yet any way to declare one, so today it comes back `team_unknown` for every value. The **Usage tab** on the gateway detail page reads the raw usage ledger (which carries the team attribution and `cost_source` that the daily rollup drops) and surfaces unpriced requests so a pricing gap is visible rather than hidden. ## Showback / chargeback exports Export aggregated spend for any period, grouped by the dimension your finance team bills on — **user, team, agent, model, provider, or a custom tag** (`cost_center` / `project` / …). Tag agents via their `tags` field, then group by `tag:`. ```bash theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) # CSV of last-30-day spend by team curl "https://api.knoxcall.com/v1/ai-gateway/usage/export?group_by=team&period=30d&format=csv" \ -H "Authorization: Bearer $TOKEN" # JSON grouped by a cost-center tag curl "https://api.knoxcall.com/v1/ai-gateway/usage/export?group_by=tag:cost_center&period=90d" \ -H "Authorization: Bearer $TOKEN" ``` Each row carries `requests`, `input_tokens`, `output_tokens`, `cost_usd`, and `unpriced_requests` (so a pricing gap is visible, never hidden). `group_by` is a strict whitelist and a tag key is bound as a parameter — no injection surface. ## Provable budget enforcement "Budgets" that only warn are theatre. KnoxCall's enforcement is covered by a test suite that exercises each overage action against the budget store: * **block** — once the daily/monthly cap is reached, further calls are rejected before reaching the upstream (no spend past the cap). * **warn** — the call proceeds with `X-Knox-AI-Budget-Warning`; nothing is silently dropped. * **per-call token ceiling** — a request asking for more output than the agent's `budget_per_call_max_tokens` is rejected up front, so one call can't blow the budget in a single shot. Enforcement reads a cross-worker store (Redis when enabled) so the cap holds across every worker, not per-process. Cache hits ([response caching](/ai-gateway/quickstart#response-caching)) cost 0, are recorded with `cost_source = 'cache_hit'`, and never charge the budget. ## Spend-spike detection Budgets catch a **hard ceiling**. A spend spike is the softer failure — a 5x jump well under the cap, arriving on the invoice a month later. A daily job sweeps each agent's rolled-up daily spend and raises an alertable `ai_gateway.spend_spike` audit event when the most recent day clears **all four** of: | Condition | Default | | - | - | | Enough prior history to have a baseline | 7 days | | An absolute floor, so cents of noise never alert | \$1.00 | | Above the robust upper band (median + 3 × scaled MAD) | — | | Also a multiple of the baseline median | 3× | The baseline is a **median + MAD** (median absolute deviation), not a mean and standard deviation. The daily rollup gives at most \~30 points per agent, and a single earlier spike would poison a mean-based baseline into never firing again. The event carries the agent id, the day's spend, the baseline and the multiple. Watch it with an alert rule on the `ai_gateway_event` type: ```json theme={"dark"} { "name": "AI spend spike", "alert_type": "ai_gateway_event", "route_scope": "all", "condition_json": { "audit_action": "ai_gateway.spend_spike", "window_seconds": 86400 }, "severity": "high", "notification_channels": ["email"] } ``` `POST` that to `/admin/alerts` with your tenant header. The same rule shape works for any AI-gateway audit action — `ai_gateway.unpriced_model`, `ai_gateway.geo_velocity_anomaly`, `ai_gateway.canary_leak`. `ai_gateway_event` is not yet offered in the admin UI's alert-type picker, so this rule has to be created through the API. The job that raises the event runs once a day on a single scheduler process — it needs at least a week of usage before it can say anything, and it reads the hourly usage rollup rather than raw requests. ## Where to look * **Usage tab** — spend and request counts by period, with attribution + export. * **Models tab** — the effective pricebook and each model's allowlist status. * **OpenTelemetry** — `knoxcall.ai_gateway.cost_usd` on every [span](/ai-gateway/observability) for your own dashboards. # Bring Your Own Anthropic Key Source: https://docs.knoxcall.com/ai-gateway/byo-key AI features run on your own Anthropic key, configured under Settings → Integrations — never a shared KnoxCall key. Here's why, and what happens when a key isn't set. # Bring Your Own Anthropic Key KnoxCall's tenant-facing AI features — the workflow **AI step**, the workflow **copilot**, and **AI-assisted route/spec setup** — run on **your own Anthropic API key**. KnoxCall does not fall back to a shared operator key for your AI usage. This keeps your AI spend on your own account (visible in your Anthropic console, on your billing), and keeps a shared key from being exhausted by one tenant's traffic. ## Add your key 1. Go to **Settings → Integrations → Anthropic**. 2. Paste your Anthropic API key (`sk-ant-…`) and save. Your key is stored encrypted per tenant (envelope-encrypted under your [tenant master key](/essentials/tenant-kms/overview)) and is only ever used server-side at egress. It is never returned to the browser. Rotating the key in Settings takes effect immediately — the gateway re-reads it on the next call. ## What happens without a key Each feature degrades safely rather than silently using someone else's key: | Feature | Without a key | | - | - | | **Workflow AI step** | The step fails with a clear message: *"AI features require your own Anthropic API key. Add it under Settings → Integrations → Anthropic to enable AI steps."* | | **Workflow copilot** (draft / explain) | Returns `HTTP 402` with code `ANTHROPIC_KEY_REQUIRED`; the builder prompts you to add a key. | | **AI route / spec setup** | Falls back to KnoxCall's built-in **local heuristics** (no AI) and shows a prompt to add your key for AI-powered analysis. | ## How the gateway uses your key When a workflow AI step runs, KnoxCall lazily provisions a **system agent** in your AI Gateway and routes the step through it — so first-party AI traffic gets the same [budgets](/ai-gateway/budgets-finops), [firewall](/ai-gateway/firewall), [PII handling](/ai-gateway/pii-redaction), and [usage attribution](/ai-gateway/budgets-finops) as the rest of your AI traffic. Your Anthropic key is mirrored into a KnoxCall-managed secret and injected by the system route at egress; the step authenticates with a short-lived phantom token, never the raw key. Provider keys for your **own** gateway agents (the ones you create) are configured on their route's injected secret, exactly like any other KnoxCall route. "Bring your own key" here refers specifically to the **Anthropic Integration** that powers KnoxCall's built-in AI features. # AI Gateway Changelog Source: https://docs.knoxcall.com/ai-gateway/changelog What changed in the AI Gateway and when — including the behaviour changes that could break a working integration, stated plainly rather than buried. # AI Gateway Changelog Newest first. Entries marked **⚠ Behaviour change** could alter what a working integration sees; everything else is additive. This changelog was backfilled on 2026-08-25 from the commit history, and again on 2026-09-07 for the 2026-08-26 → 2026-09-03 window, so entries are reconstructed rather than written at the time. Dates are the date the change landed on the integration branch, not the date it reached production. A change under `src/ai-gateway` now has to touch this file or say in its commit that it does not need to — see the docs-truth guard in `tests/coverage/`. *** ## 2026-10-08 — ⚠ Behaviour change: the PII token-map view asks for a fresh second factor and is recorded The admin API's debug read of a conversation's PII token map, `GET /admin/ai-gateway-pii/token-map/{conversationId}` (no dashboard page calls it), shows each original value's first three characters and its length. That still narrows a card number, an SSN or an email a long way, so it is now a **reveal**, like listing a vault's tokens: every request asks for a fresh second factor (an authenticator code, a passkey or a security key), writes a `plaintext.reveal` entry to the audit log naming the conversation **before** anything is decrypted, and counts against the per-person reveal budget. Without a fresh verification the request is refused with the same step-up refusal every dashboard reveal answers (the dashboard's own codes, listed in the [API changelog](/api-reference/changelog) entry for 2026-10-06); verify through the step-up endpoints and repeat it. If the audit entry cannot be written, or the budget cannot be counted, it is refused rather than served. Each view that is served also writes an `ai_gateway.pii_token_map_viewed` entry naming the conversation and how many tokens it held (never an original value), and the excessive-reveals security alert counts it together with secret reveals, vault detokenizations and the other dashboard reveals. **Who can use it does not change:** an Owner or an Admin, as before. A conversation the gateway cannot address (a name with no `?principal=`) is still a `400`, answered before anything is asked for or recorded. **Not affected:** the data plane. Tokenizing a prompt and restoring values in the response for the caller who sent them, and the playground's redaction view of the prompt you just typed, work exactly as before. See [PII redaction](/ai-gateway/pii-redaction). *** ## 2026-10-05 — ⚠ Behaviour change: responses through a static-egress route no longer carry `X-Egress-*` headers When an agent's route sends its calls through a **static-egress server** (a fixed outbound IP), the egress server adds four headers of its own to the provider's response: `X-Egress-Request-Time`, `X-Egress-Upstream-Time`, `X-Egress-Connect-Time` and `X-Egress-Server-IP`. They describe KnoxCall's egress infrastructure, not your provider, and the last one is the egress server's own address. Until now they were passed back to you on every `/v1/ai/{slug}/…` response that went through such a route. They are no longer returned. Any response header in the `X-Egress-` namespace is now withheld, including one your provider sends itself, on every plane that returns a provider's response: the AI Gateway, the route proxy and `/v1/proxy`. The provider's own status, body and every other header are unchanged. **What to do.** If your integration read these headers, it will no longer find them. The AI Gateway does not expose these egress timings anywhere else today. Calls on routes that do not use a static-egress server never had them and are unaffected. *** ## 2026-10-03 — The gateway's own side calls are bounded as a whole, not only when they go quiet Four calls the gateway makes on your behalf, beside the provider call itself, already had a timeout: your **guardrail webhook**, your **Presidio analyzer**, the **embeddings lookup** behind the semantic cache, and **OIDC discovery / JWKS** for workload identity. That timeout measured silence. An endpoint that kept sending a byte at a time never went silent, so it was never cut. Each of those calls now also ends when its timeout has passed in total, whatever the endpoint is doing. The numbers are the existing timeouts: the guardrail webhook's own setting on the agent (100 ms to 10 s, default 2 s), the Presidio analyzer's `timeout_ms` (default 1.5 s), 4 s for the embeddings lookup and 10 s for discovery and JWKS. The outcome is the one a timeout already had: * guardrail webhook: your configured failure action applies (`fail_open` or `fail_closed`), with the reason "did not answer within N ms"; * Presidio: the scan is skipped for that request, counted by the circuit breaker, and the built-in detectors still run; * embeddings: a semantic-cache miss; your completion is sent to the provider as usual; * discovery / JWKS: the token exchange fails as it does for an unreachable issuer. **What to do.** Nothing. If your guardrail webhook or your Presidio analyzer routinely takes longer in total than the timeout you set for it, raise that timeout: it is now the budget for the whole call. The provider call itself (`/v1/ai/{slug}/…`) is unchanged by this entry. *** ## 2026-10-03 — ⚠ Behaviour change: a provider that hangs up part-way through a response fails the call instead of leaving it open When a provider sent its response headers and then closed the connection before the body was complete — a body shorter than its `Content-Length`, or a chunked body with no final chunk — a **non-streaming** `/v1/ai/{slug}/…` call was never answered. The gateway kept waiting for a body that could no longer arrive, so the caller saw nothing until its own timeout. That call now fails as soon as the connection closes, and is handled like any other failed upstream attempt: * if the agent has a fallback route, or its retry policy treats a transport error as retryable, the gateway moves on to the next attempt; * otherwise the caller gets `502 upstream_error` with the request id. Nothing changes for a provider that answers in full, including one whose body is ended by the connection closing (no `Content-Length`, not chunked) — that is a complete response and is returned as before. Streaming calls already reported a dropped connection and are unchanged. **What to do.** Nothing, unless you were relying on your own client timeout to detect this: you will now get a `502` (or a successful fallback) sooner. Treat it as retryable. *** ## 2026-10-02 — ⚠ Behaviour change: headers a proxy in front of KnoxCall adds are no longer forwarded to your provider A request to `/v1/ai/...` reaches KnoxCall through an edge that adds headers describing the connection to us. Some of those were being passed on to your model provider, along with two of KnoxCall's own. They are now withheld, each matched by its exact name: `X-KnoxCall-Env`, `X-KnoxCall-Origin`, `X-Real-IP`, `True-Client-IP`, `CF-Worker`, `CF-EW-Via`, `X-Client-Cert-Verify`, `X-Client-Cert-Thumbprint`, `X-Client-Cert-Subject`, `Cf-Access-Jwt-Assertion`, and the other request headers Cloudflare adds. The [API changelog](/api-reference/changelog) names every one, and [Request headers KnoxCall does not forward](/api-reference/request-headers) is the complete list. **What to check.** If your client sends one of these names itself and your provider or an endpoint of your own reads it, it no longer arrives, and nothing in the response says so. To send one, add it to the injected headers of the route behind the agent: an injected header is sent with the value the route configures. A header that only resembles one of these is unaffected. `cf-aig-authorization` and the other headers you send to a Cloudflare AI Gateway upstream are still forwarded. *** ## 2026-09-30 — Latency figures are measured on a clock that cannot go backwards KnoxCall's own latency, the upstream timing and time-to-first-byte — in the `X-Knox-*` response block, `Server-Timing`, and the request log — were computed by subtracting two wall-clock readings. When the host's clock was corrected mid-request (an NTP step), a figure could come out wrong, including **negative**, and the upstream time could exceed the total it is part of. They are now measured on a monotonic clock, so a clock correction cannot bend them and the upstream time is never larger than the total. The one visible difference on a normal request: figures are rounded **up** to the whole millisecond rather than truncated, so a value may read 1 ms higher than before. The timestamps on the request timeline are unchanged and remain wall-clock. *** ## 2026-09-29 — ⚠ Behaviour change: a KnoxCall dependency outage answers `503 dependency_unavailable` with `Retry-After`, not a 500 or 502 When KnoxCall cannot reach one of its **own** dependencies in time — its control-plane database (a connection pool that could not hand out a connection, a dropped connection, a server that refused the statement) or its rate-limit store — the request is now refused with a typed, retryable answer instead of a generic server error. **Affected endpoints and what changes.** | Endpoint | Where the failure lands | Before | Now | | - | - | - | - | | `/v1/ai/{slug}/…` | agent or route load, and the buffered dispatch | `500 internal_error` or `502 upstream_error` | `503 dependency_unavailable` | | `/v1/ai/{slug}/…` | phantom-token lookup (the gateway's outermost boundary) | `502 upstream_error` | `503 dependency_unavailable` | | `/v1/mcp/{slug}/…` | the gateway's outermost boundary | `502 upstream_error` | `503 dependency_unavailable` | **The response.** The AI plane's usual envelope, plus a `Retry-After` header — whole seconds, `5` today (one pool checkout interval): ```text theme={"dark"} HTTP/1.1 503 Service Unavailable Retry-After: 5 Content-Type: application/json; charset=utf-8 ``` ```json theme={"dark"} { "error": "dependency_unavailable", "error_description": "KnoxCall could not reach its control plane in time. The request was not forwarded; retry after the Retry-After interval.", "code": "dependency_unavailable", "request_id": "0f9c1a52-…" } ``` The description names the coarse class — the control plane or the rate-limit store — and never the component, the reason or a driver code. **What could break you.** If your integration treated a `500`/`502` from the gateway as "KnoxCall is broken, do not retry", the same outage now arrives as a `503` that says **do** retry, after `Retry-After`. Nothing reached your provider, so resending is safe and costs no provider tokens. It is **not** a credential problem — do not re-mint a phantom token because of it — and it is not your budget or rate limit. The official SDKs honour `Retry-After` on a `503` as they do on a `429` from their next release. Every other gateway `503` (`firewall_policy_unavailable`, `pii_policy_unavailable`, …) is unchanged. The same `503 dependency_unavailable` is answered on the route data plane, on `/v1/proxy`, and on the `/v1` control plane — see [Response metadata](/api-reference/response-metadata) and [Errors](/api-reference/errors). *** ## 2026-09-21 — ⚠ Behaviour change: every AI-gateway response carries a labelled `X-Knox-*` block, and upstreams can no longer forge one Three things change on `/v1/ai/{slug}`, on all three response paths — cache hit, streamed and buffered. **New response headers.** A KnoxCall-authored block, separate from whatever your upstream said: a request id, which side answered, which plane served the call, KnoxCall's own latency and what that number measured, and — when a real upstream answered — that upstream's status and its timing. The exact header names, their values and how to branch on them are in [Response metadata](/api-reference/response-metadata), which is the one place the full block is specified so this page cannot drift from it. A `Server-Timing` entry is **appended** (never overwritten — it is a shared list and your provider's own timings stay). **Why it exists.** Three situations were indistinguishable from the response alone: your upstream answered 429, KnoxCall answered 429, or KnoxCall never reached your upstream at all. A wrapped provider SDK gets this actively wrong — `stripe-node` reads a KnoxCall 429 as a Stripe rate limit and retries into the limit that is refusing it. **The load-bearing signal is `X-Knox-Upstream-Status`: present if and only if a real upstream response was received.** `X-Knox-Origin` is the same fact spelled for a human reading `curl -i`, and is derived rather than passed, so a disagreeing pair is unrepresentable. **What could break you.** If you read the `x-knox-` response namespace today, you were reading values an **upstream could set**. Before this change `NEVER_RETURNED_UPSTREAM_HEADERS` was hop-by-hop plus cookie-setting only, so the whole namespace relayed verbatim — a measured example returned attacker-chosen `X-Knox-Origin`, `X-Knox-Error`, `X-Knox-Upstream-Status` and `X-Knox-Request-Id` to the caller unchanged. On `/v1/proxy` the upstream's values also **overwrote KnoxCall's**, including the request id you quote to support. The namespace is now refused at the relay chokepoint **by prefix**, so a field added to the block tomorrow is not spoofable during the window before someone remembers to list it. If your integration depended on an upstream being able to set `x-knox-*`, it will stop working — deliberately. Nothing else in the response body or status changes. **`Latency-Phase` is labelled, not guessed.** `complete` on a buffered response, `first-byte` on a streamed one, because headers must flush before a streamed body and the number cannot mean the same thing in both. The upstream/overhead split is **omitted rather than estimated** where it cannot be measured honestly. ## 2026-09-20 — DPoP responses now carry a server nonce (RFC 9449 §8) Every gateway response to a request that presents a `DPoP` header now carries a `DPoP-Nonce` header, and a proof that **chooses to include** a `nonce` claim must include one this platform issued in the last two minutes. **Nothing you send today changes.** A proof with no `nonce` claim — which is every proof any current SDK or tool produces — is accepted exactly as before. The nonce is advertised so that clients can start echoing it; the SDKs learn to do that in a later release. **What it is for.** A DPoP proof is signed entirely from values the client picks, so proofs can be generated in advance and used from anywhere inside the `iat` window. The nonce is the one value the server picks: echoing it proves the holder of the key was talking to us in the last couple of minutes, not at some earlier point on some other machine. **If you do send a nonce and it is stale**, the request is refused with HTTP 401, `WWW-Authenticate: DPoP error="use_dpop_nonce"`, and the refusal itself carries a fresh `DPoP-Nonce` — re-sign once with that value (use a new `jti`) and retry. The stale-nonce refusal deliberately does not spend your proof's `jti`, so the retry is not refused a second time for a different reason. Applies to `/v1/ai/*` and `/mcp` (the phantom-token authenticators). The gateway's own federated mint at `/v1/oauth/token` still issues bearer tokens and reads no DPoP header — that is unchanged and tracked separately. *** ## 2026-09-16 — ⚠ Behaviour change: the card-number refusal now covers MCP tool calls and every SDK request The 2026-09-14 entry below closed the raw-card gap on the **route-executor** dispatch and said plainly that two paths on this plane were still open. Both are now behind the same refusal. **MCP tool calls.** A `tools/call` whose **arguments** carry a payment card number in the clear is refused before the upstream is dialled, unless the MCP server's host is on the workspace's raw-card egress allowlist. Arguments are scanned at any depth, including object keys. The caller sees a JSON-RPC error with code `-32000` — the code this gateway already uses for a governance refusal — carrying the same message the HTTP surfaces return, naming the destination and the two remedies and containing no digit of the card. It is **not** `-32603`: this is our refusal, not your MCP server failing, and an alert on upstream failures must not count it. **Every other request an SDK makes.** The pinned `fetch` the gateway hands to an SDK now runs the same scan on each request body, per redirect hop, under the surface name `ai_gateway_sdk_fetch`. That covers the MCP transport's own traffic and anything else built on the wrapper. **What is unchanged.** Detection is the same — 13–19 digits (spaces and hyphens tolerated) that pass the Luhn checksum **and** carry a recognised issuer prefix — so an ordinary 16-digit reference number is unaffected, and a `pan`-vault token is excluded by lookup rather than by shape. The allowlist, the audit entry (`egress.raw_pan_refused`) and the month-to-date counter are the same ones the 2026-09-14 change introduced. A refused tool call writes exactly **one** audit entry and **one** counter tick. **The remedy is the same.** Take the number out of the tool arguments, or have an owner or admin add the destination with `PUT /admin/pan-egress/allowed-hosts`. *** ## 2026-09-16 — A private Kubernetes cluster can now federate: three JWKS sources Until now KnoxCall could only verify an issuer whose signing keys it could fetch over public HTTPS via OIDC discovery. That excluded the most common self-managed setup: a Kubernetes cluster whose issuer is `https://kubernetes.default.svc` and is reachable only from inside the cluster. An issuer's keys can now come from one of three sources, chosen per issuer: | mode | keys from | network call | | - | - | - | | `discovery` | `/.well-known/openid-configuration` → `jwks_uri` | yes | | `explicit_url` | a JWKS URL you supply | yes | | `inline` | a JWKS document you paste | **none, ever** | **`discovery` remains the default and nothing about an existing issuer changes.** This is additive: if you have never configured a JWKS source, you have one, and it behaves exactly as before. Set it in the Dashboard under **Settings → Workload Identity**, or with `POST /admin/federation/issuers/jwks-source`. Changing it requires a **recent step-up verification** made with a second factor for the `oauth.client` action (an authenticator-app code, a passkey or a security key) — pasting keys is telling KnoxCall which signer to believe for your tenant, so it carries the same bar as creating a binding. Switching an issuer back to `discovery` is gated too. *Corrected 2026-10-04: this entry first said an emailed code would do. This gate has never accepted one. Security keys can be used from 2026-10-04.* The document is parsed and rejected **when you save it** if it cannot produce a usable key, rather than letting you discover the problem on your first exchange. **Inline keys do not refresh.** When your cluster rotates its service-account signing keys, exchanges stop verifying until you paste the new document. KnoxCall names that cause specifically rather than reporting a generic signature failure, and the binding's status in the Dashboard shows it — but nothing rotates them for you. If your cluster rotates on a schedule, put this on the same schedule. **Keys are held per tenant, and an assertion is only ever verified against the keys of the tenant whose binding it matched.** `https://kubernetes.default.svc` is the default issuer string for *every* cluster, so a different customer registering the same issuer string cannot mint into your tenant, and you cannot mint into theirs. Full guide: [Kubernetes workload identity](/api-reference/workload-identity/kubernetes). *** ## 2026-09-16 — ⚠ Behaviour change: token-exchange refusals no longer say WHY `POST /v1/oauth/token` and `POST /oauth/token` now give **one** answer to every refusal that depends on your workspace's configuration: ``` the subject_token was not accepted for any binding on this issuer ``` Previously they distinguished "no binding is registered for this issuer" from "a binding exists but your claims did not match" from "the signature did not verify". Those are different facts about *a workspace*, and the exchanges answer **unauthenticated** callers — so anyone who could POST could learn whether some KnoxCall customer federates from a given private issuer. Harmless for a shared issuer like GitHub's; not ours to publish for a company's own Okta or Entra tenant. **Refusals about YOUR OWN TOKEN are unchanged and still specific** — a malformed JWS, an unsupported `alg`, a missing or expired `exp`, the wrong `subject_token_type`. Those say nothing about any workspace, and blurring them would have removed the diagnostic for the commonest failure while closing nothing. **Where the reason went.** Two places, both of which authenticate you and scope to your own bindings: * **Settings → Workload Identity → the binding's status**, and the new `GET /admin/federation/auth-history`, which lists your refused exchanges with the real reason. * The **connect-workload wizard's diagnose step**, which runs a real token through the exchange's own verifier and tells you exactly which check failed. This ordering was deliberate: the history shipped first, because making the error opaque before it existed would have left tenants with no diagnostic at all. *** ## 2026-09-16 — ⚠ Behaviour change: an assertion with no `kid` is now refused `POST /v1/oauth/token` and its management twin `POST /oauth/token` require the subject token's JWS header to carry a **`kid`** naming the issuer's signing key. **What changed.** Previously a `kid`-less assertion was accepted when the issuer published **exactly one** key, and refused only when it published more than one. So the rule's strength depended on the issuer's *current* key count — which the issuer changes without telling anyone. An IdP that normally publishes two keys (making `kid` effectively mandatory) publishes one for the length of a rotation, and in that window a `kid`-less token became acceptable. It is now refused unconditionally. **Who this can break:** an issuer that omits `kid` *and* publishes a single key. Every mainstream IdP sets it by default; if yours does not, it is usually a signing configuration option rather than a limitation. The refusal is `invalid_grant` with `subject_token has no kid; KnoxCall requires the JWS header to name the signing key`, and the binding's diagnose view explains it in full. **More signing algorithms are accepted** — additive, nothing previously working stops working. The set is now the full asymmetric range: `RS256/384/512`, `PS256/384/512`, `ES256/384/512` and `EdDSA`, where before it was `RS256`, `ES256` and `EdDSA` only. Okta, Entra and some SPIRE profiles sign with algorithms we used to refuse. **HMAC (`HS*`) and `none` remain refused and always will be** — the only key material we hold for your issuer is its *public* JWKS, so accepting an HMAC would mean verifying a token signed with the key we ourselves published. **Attribute conditions may now end in `*` to match by prefix** — additive — and a prefix too broad to identify a workload (`repo:*`, or a bare `*`) is refused both when you create the binding and when a token is exchanged against it. A trailing wildcard only: no infix, no leading. *** ## 2026-09-16 — ⚠ Behaviour change: token-exchange refusal *wording* changed `POST /v1/oauth/token` and its management twin `POST /oauth/token` now run **one shared verification pipeline** instead of two near-identical copies. Nothing about which requests are accepted or refused changed, and **the RFC 6749 `error` codes are identical** — `invalid_grant`, `invalid_target`, `invalid_request`, `unsupported_grant_type`. What did change is the human-readable `error_description` on the gateway exchange, which now matches the management one word for word: | Before | Now | | - | - | | `Signature verification failed` | `subject_token signature invalid` | | `subject_token is expired` | `subject_token expired` | | `subject_token iat is in the future` | `subject_token iat in the future` | | `No binding matched the subject_token claims` | `no binding matched the subject_token's claims` | **If your CI matches on `error_description` text, update it — or better, switch to the `error` code, which is stable and is what RFC 6749 intends you to branch on.** We are flagging a wording change because a string match is a real integration even though it is a fragile one. Why do it at all: the two exchanges had drifted into separate implementations of the same checks, which is how a control ends up correct on one path and simply absent on the other. They are now one pipeline with two mint adapters, and the differences that remain — the audience each mints for, the clock-skew tolerances — are explicit, documented fields rather than an accident of two files. *** ## 2026-09-14 — ⚠ Behaviour change: a clear card number in a prompt is refused A request whose body carries a **payment card number in the clear** — in the prompt, in `messages`, in tool arguments, or in any other field — is no longer forwarded to the model provider, unless the provider's host is on the workspace's raw-card egress allowlist. The allowlist is **empty for every workspace by default**, so this refuses on first contact rather than after a configuration change. **What the caller sees.** `403` with code `raw_pan_egress_refused`. The message names the destination and the two remedies and contains no digit of the card. The request is refused before any bytes reach the provider; a failover candidate is **not** tried, because sending the same card to a different provider is not a recovery. Each refusal writes an `egress.raw_pan_refused` audit entry carrying the destination host and a masked sample (last four only), and increments a month-to-date counter readable at `GET /admin/pan-egress/allowed-hosts`. **The remedy.** Either take the number out of the prompt, or — if a provider genuinely must receive it — have an owner or admin add that host with `PUT /admin/pan-egress/allowed-hosts`. Adding a host requires a fresh second factor; removing one does not. **PII tokenization is unaffected.** The surrogate that `pii_request_mode` mints for a card is format-preserving, so it is Luhn-valid and looks exactly like a card; it is exempted by construction (a value present after tokenization and absent before it is, by definition, something the tokenizer minted). An agent with `pii_request_mode: 'tokenize'` therefore keeps working unchanged. What is newly refused is a card the tokenizer does **not** reach: a non-prompt field, or an agent with `pii_request_mode: 'off'` — which is the default on every system-role agent. **What this does NOT cover yet.** Two egress paths on this plane are **not** behind the refusal: **MCP tool calls** (`tools/call` arguments to a tenant-chosen MCP upstream) and the **SDK-level fetch wrapper** every gateway SDK is handed. The wrapper is the real gateway chokepoint and moving the control down to it is a follow-up lane; until then, a card number sent through either path still reaches the upstream. Detection is 13–19 digits (spaces and hyphens tolerated) that pass the Luhn checksum **and** carry a recognised issuer prefix, so an ordinary 16-digit reference number is not affected. **Closed on 2026-09-16** — see the entry above. This paragraph is kept as written because the gap was real for two days, and a changelog that quietly edits away what it admitted is worth less than one that says when it was fixed. ## 2026-09-13 — Workload-federation refusals now appear in your denial events `POST /v1/oauth/token` (and its management twin `POST /oauth/token`) refused on their own and wrote nothing to the audit chain. A run of expired, replayed or wrongly-scoped workload tokens against your gateway produced **no** record: nothing in an audit export, nothing for an alert to group on. The token planes stopped being silent like that in AIGW-121; the exchanges were the surface that fix did not reach. **What changes.** A refused exchange now writes the same `ai_gateway.request_denied` entry every other refusal on this platform writes, carrying `plane: "federation"` and the exact outcome in `detail.code`. Reasons reuse the existing grouped set where they mean the same thing — a replayed single-use assertion is `auth_oneshot_replay`, a scope the binding does not allow is `auth_scope_denied` — plus two new ones: `federation_binding_unusable` (the binding matched but its own configuration cannot mint — a revoked backing key, or a `subject_claim` that resolved to nobody) and `federation_resource_denied` (an RFC 8707 `resource` the binding cannot be confined to). **Only refusals that reached one of YOUR bindings are recorded**, and that is deliberate rather than incidental. Before a binding matches, the request has proven nothing about which tenant it belongs to — and issuers like GitHub's are shared by every repository in the world — so attributing those attempts to a tenant would publish one customer's workload traffic in another's audit trail. A bad signature, an unknown issuer or a rule that matched nothing therefore still writes no row. Successful exchanges are not denials and write none; a KnoxCall-side error is recorded as our failure, not as a refusal of yours. Nothing to change on your side — existing bindings and exchanges behave exactly as before. If you export audit events or alert on `ai_gateway.request_denied`, expect federation refusals to start appearing in that stream. ## 2026-09-12 — Fixed: the Presidio sidecar image could not be built Self-hosted only. `docker/presidio-sidecar/Dockerfile` copied a `recognizers/` directory that was never in the repo, so the build command in its own header failed at that line — for anyone who tried it, since PR-12. The copy was never needed: custom recognizers are pushed to the running sidecar over HTTP (`POST /reload-custom-recognizers`) and held in memory, so the image does not carry them. The image is now built and vulnerability-scanned in CI alongside the agent image, so it cannot quietly stop building again. ## 2026-09-12 — ⚠ Behaviour change: only a request that asks the provider to stream is streamed — and only where it can be redacted Three ways a response could reach you through the streaming path without being read by the mid-stream redactor, each now closed: * **An `Accept: text/event-stream` header alone** put a request on the streaming path. With `stream: false` the provider answered plain JSON, and that JSON went to you with **no PII redaction, no detokenization and no canary scan**. The header no longer counts: such a request is served on the buffered path and fully redacted. Send `stream: true` to stream. * **A streamed call to the OpenAI Responses API or legacy completions** (`/v1/responses`, `/v1/completions`) streamed an event family the redactor does not read, so it passed through **unredacted**. It is now refused with `400 streaming_redaction_unsupported`; the same call without `stream: true` is served and redacted. * **Gemini streaming** (`:streamGenerateContent`) was not recognised as streaming at all, and the `?alt=sse` it needs was not forwarded. It is now streamed through the Gemini redactor, with `alt=sse` supplied by the gateway. Two refusals are new. An agent with `streaming_enabled: false` now refuses a request that asks to stream (`400 streaming_disabled`) instead of receiving the provider's stream on the buffered path, where a value split across two frames could not be matched. And a streamed request the provider answers with something other than an event stream is refused (`502 upstream_stream_shape_unexpected`) rather than forwarded unread. See [Streaming](/ai-gateway/pii-redaction#streaming-what-has-a-mid-stream-redactor-and-what-refuses). ## 2026-09-12 — ⚠ Behaviour change: a Presidio call that fails on one request is no longer treated as a clean scan An analyzer that resolved fine and then failed on the call — a timeout, a 5xx — returned "no matches", which is what "found nothing" looks like too. The request was scanned by the in-process tiers alone and recorded as a clean scan. Those tiers do not detect names, street addresses or medical record numbers, which is most of what a policy with an analyzer attached is for. A failed call is now reported, and gets the same two postures a resolve-time failure has had since 2026-09-07: * **With a PII policy attached** — `503 pii_policy_degraded`, and the prompt is not forwarded. This is **retryable**: it says this call lost NER coverage, not that the analyzer is down. * **With no policy attached** — served, with `X-Knox-AI-Pii-Degraded: presidio_call_failed` and a `detector_unavailable` row in the PII events ledger. **What you should do.** If you attach a policy that uses Presidio, make sure the sidecar is sized for your traffic and retry a `503 pii_policy_degraded`: before this change those requests succeeded with part of your policy silently not applied. See [PII redaction](/ai-gateway/pii-redaction#presidio-optional). ## 2026-09-12 — ⚠ Behaviour change: an address that keeps failing authentication is refused at the door `/v1/ai` and `/v1/mcp` now refuse an address that has failed authentication 60 times in 5 minutes, with `429 rate_limit_exceeded` and a `Retry-After`, **before** its token is checked. Only refused tokens count — missing, unknown, expired, revoked or out of scope — so successful traffic from a shared address is never affected. The two planes share one allowance per address. Until now nothing stopped an unauthenticated caller from making either plane look up a token and write a denial record on every request, and `/v1/mcp` also parsed up to 4 MB of request body before checking the token at all. It now checks the token first. A request with both a bad token and a malformed body therefore gets `401`, where it used to get `400`. **What you should do.** Nothing, unless a service of yours is retrying with a revoked or expired token: after 60 such failures from one address, that address is refused on both planes until the window drains. Fix the credential and wait out the `Retry-After`. See [Limits](/ai-gateway/limits). *** ## 2026-09-12 — ⚠ Behaviour change: the system-prompt canary no longer replaces a block-array system prompt, or adds a field other APIs reject On an agent whose firewall policy has canary tokens enabled — the default — the gateway adds a canary to the system prompt of each request. It only knew two request shapes, and every other shape was handled as one of them: * **Anthropic `system` as a block array** — the form prompt caching uses — was **replaced** by the canary. The request succeeded, and the model never saw the system prompt you sent. * **Gemini, OpenAI Responses, Bedrock Converse, Cohere v1, embeddings and legacy completions** requests had a `messages` field added that those APIs do not define. Gemini and the OpenAI Responses API reject a request carrying an unknown field, so those requests failed at the provider. * An agent on an Anthropic upstream whose model name the gateway did not recognise was sent a `role: "system"` message, which the Messages API refuses. **What changes.** The canary now goes where each API keeps its system prompt, alongside yours — see [Where the canary is placed](/ai-gateway/firewall#where-the-canary-is-placed). Requests that have no system prompt (legacy completions, embeddings, batch envelopes) are forwarded without a canary rather than with an invented field. **What you should do.** If an agent appeared to ignore its system prompt, or Gemini or Responses calls through the gateway failed with an unknown-field error, this was the cause, and nothing on your side needs to change. ## 2026-09-11 — ⚠ Behaviour change: streamed tool-call arguments are now scanned for PII, and arrive when the call finishes A tool call's arguments are model output, and they routinely carry values the model lifted from the prompt. On a **streamed** response they were not scanned: OpenAI `tool_calls[].function.arguments` (and the older `function_call`), Anthropic `input_json_delta` and Gemini `functionCall.args` went to your client exactly as the model wrote them — while the same response's text was redacted, and the same arguments were scanned on a non-streamed response. **What changes.** Arguments are now scanned on the streaming path too — every key and value, including numbers — and under `pii_response_mode: "detokenize"` any `KC_*` placeholder in them is restored, so the tool you run receives your original values instead of a placeholder. Because arguments stream as JSON fragments that cannot be rewritten one at a time, the gateway holds each call's arguments until the call finishes and releases them as one fragment: * The call's first chunk (`id`, function `name`) still arrives when the call starts; the **arguments arrive all at once when the call finishes**. A UI that renders arguments character by character will see them appear in one step. * Arguments with nothing detected are byte-for-byte unchanged. When something is redacted, the document is re-serialised with compact whitespace. * A system-prompt canary in the arguments is caught before any of them leave. * A single call over 4,194,304 characters ends the stream with an `event: error` frame (`tool_arguments_too_large`) instead of being released unscanned. `monitor` streaming mode is unchanged: arguments pass through and are scanned for the record. See [Streamed tool calls](/ai-gateway/pii-redaction#streamed-tool-calls). *** ## 2026-09-11 — Fixed: streamed answers were cut short in the official OpenAI and Anthropic SDKs On an agent with response-side PII redaction (the default) streaming in the `holdback` mode (the default), the last characters of each streamed answer — up to the hold-back window, 96 by default — were sent **after** the provider's own end-of-stream marker: after `data: [DONE]` on OpenAI-shaped providers, and after `message_stop` with no `event:` line on Anthropic. The official SDKs stop reading at those markers, so they never delivered those characters. The answer came back shorter than the model wrote it, with no error on either side. A client reading raw SSE to the end of the connection received the whole text. **What changes.** The held-back characters are now released before the provider's end marker for the block they belong to — before `content_block_stop`, before the `finish_reason` chunk, inside Gemini's `finishReason` chunk — and every frame the gateway writes on an Anthropic stream carries its `event:` name. Nothing to do on your side; if streamed answers through the gateway ended mid-sentence, this was the cause. *** ## 2026-09-11 — ⚠ Behaviour change: a streamed response that leaks your system-prompt canary is now stopped under a `block` policy Until now a `block` firewall policy refused a canary leak only on a **buffered** response (`400 canary_leak`). On a **streamed** response the leak was recorded and the canary itself was spliced out, but the stream carried on to the end — so the rest of an answer written by a model that had just revealed your system prompt still reached the caller. **What changes.** Under a `block` policy a streamed response now stops at the frame that carries the leak and ends with an `event: error` frame whose `code` is `canary_leak`. It cannot be a `400`: the `200` and the first frames have already gone out. Nothing after the leak is delivered, and anything sent before it contained no canary. The error frame reports a `matched_count`, never the token. **Who notices.** Only agents that stream and have a `block` firewall policy attached. A client reading the stream sees an `event: error` frame and an early end instead of a complete answer — handle it the way you handle any mid-stream provider error. Under `warn`, nothing changes. **One exception.** In the `monitor` streaming mode the response passes through unredacted and is inspected only once it has finished, so a leaked canary reaches the caller before any refusal can fire. Do not rely on a `block` policy for an agent in `monitor` mode. See [Firewall](/ai-gateway/firewall) and [Errors](/ai-gateway/errors). ## 2026-09-11 — A system-prompt canary carried through an MCP tool is now caught The canary KnoxCall places in each agent's system prompt was only watched for on `/v1/ai`. An agent that extracted it and then moved it through an MCP tool — in the call's arguments, out to an upstream, or back in a tool result — passed through `/v1/mcp` unnoticed, because that plane scanned tool traffic for everything except our own marker. **What changes.** `/v1/mcp` now scans tool-call arguments and tool results for your tenant's canaries. A match is replaced with `[REDACTED:CANARY]` before the arguments leave for the upstream and before the result reaches the caller, and raises the same critical `ai_gateway.canary_leak` audit entry as the chat plane — recorded against the **MCP server** rather than an agent, one per call, carrying a count of matches and never the token. The call itself is still served: this plane redacts and alerts rather than refusing. **Who notices.** Nobody in normal operation — a canary only appears in tool traffic if a system prompt was extracted. Scanning runs wherever the server's PII inspection is on, which is the default. If the canaries cannot be loaded, the call is served without canary scanning and the failure is logged. See [MCP gateway](/ai-gateway/mcp-gateway). *** ## 2026-09-10 — ⚠ Behaviour change: the prompt-inspecting controls were reading an empty body, and now read the real one **If you rely on any AI Gateway control that inspects the prompt, read this one.** Between **2026-08-26 and 2026-09-10**, the `/v1/ai` data plane forwarded an **empty request body** to the provider's controls. The request itself reached the model normally, so nothing looked broken — but every control that makes its decision by looking at the prompt was handed two bytes (`""`) instead of your content. **What was affected, for that window:** * the **model allowlist / denylist** * the **tool allowlist** * the **prompt-injection firewall** * the **guardrail webhook** * the **PII tokenizer** (request side) Each of them evaluated an empty prompt, so each of them **passed everything**. A policy that should have denied a request allowed it, and a prompt that should have been tokenized was not. `POST /v1/mcp` was affected differently and more visibly: it answered **`-32600` to every well-formed call**. **What changes for you now that it is fixed.** The controls now see your actual prompt, which is what they were always configured to do — so **a request that was allowed during that window may now be denied**, and PII that passed through untokenized will now be tokenized. Nothing about your configuration changed; the controls simply begin enforcing what they already said. If you tuned a policy by observing its behaviour during that window, re-check it: you were tuning against a control that was not reading anything. **What you should do.** If you depend on the injection firewall, the guardrail webhook or request-side PII tokenization for a compliance claim, treat 2026-08-26 → 2026-09-10 as a window in which those controls were **not enforcing**, and review what passed through in that period. The denial audit trail for the window records decisions made against the empty body, not against your prompts. This is announced rather than buried because it is the shape this changelog exists for: nothing errored, nothing looked wrong, and the controls were open. *** ## 2026-09-07 → 2026-09-08 — the round, in one place A single round of work landed everything between this entry and the 2026-09-03 entries, across eleven parallel workstreams. The individual entries below say exactly what each change does; this one exists so you can see the shape of the round without reading all of them, and so you can tell at a glance whether it touches you. **If you have an integration today, these are the four that can change what you see.** Each is written up in full below. * **PII is now two explicit settings, not one.** `pii_request_mode` decides whether a prompt is tokenized on the way out; `pii_response_mode` decides whether the reply is restored on the way back. They used to be one flag, and it was the response-side one — so an agent with a PII policy attached and detokenization off sent the prompt upstream untouched. Existing agents were migrated to the behaviour they had, **except** that request-side tokenization no longer depends on the response-side setting; a tenant agent with a policy attached backfills to `tokenize`. * **Three requests that used to be served are now refused**, because serving them meant serving unredacted text: a streaming request on a provider with no mid-stream rewriter is `400 streaming_redaction_unsupported`, a body the gateway cannot parse is `400 pii_unscannable_body`, and a request whose policy needs an analyzer that is down is `503 pii_policy_degraded`. With no policy attached, the third degrades to the built-in detectors and says so in a response header instead. * **A confirmed canary echo under a `block` firewall policy is refused** (`400 canary_leak`) rather than served with a `warn` verdict recorded. And a model your pricebook cannot price is refused under a `block` budget (`403 budget_unpriced_model`) rather than metering \$0 and slipping the cap. * **One error envelope on both data planes**, `{error, error_description, code}` with `error` and `code` always equal, and `Retry-After` on the 429s. The `/v1` management API and the `/v1/mcp` JSON-RPC error object are unchanged. **The MCP plane caught up with the model plane.** Tool calls are now preflighted against the gateway's USD caps, offered to the same external guardrail hook (on the arguments before dispatch and the result before return), and scanned with **your own** PII recognizers when you attach a policy to the server. MCP servers gained a Test/Live partition — a `kp_test_` token reaches only Test servers, and a mode it cannot prove is refused — so the same slug can now exist in both spaces. A parity guard whose input is the model plane's own list of refusals keeps the two planes from drifting again. **Everything else is additive:** the agent detail page edits 34 of the 36 settings the API accepts instead of 6, on one dirty-gated Save; new Connect, Requests, Cache, Federation and Usage surfaces; PII policies and recognizers on `/v1` with the full SDK, Postman, OpenAPI and CLI fan-out; a real `knoxcall ai` command group in all five SDK CLIs; every `/v1/ai-gateway` operation now rendered from the spec rather than hand-copied; and prices that tell you whether they are a vendor list price or our estimate. *** ## 2026-09-08 ### The cache hit rate on the Agent's cache panel was not a hit rate **If you enabled response caching and read a hit rate off the Agent's cache panel, that number was wrong, and for an Agent in `cache_mode: "exact"` it was always 100%.** It is right now, and the panel gained the counters that make it right. `cache_outcome` on the usage ledger declared four values — `hit_exact`, `hit_semantic`, `miss`, `bypass` — and the gateway only ever wrote the two hits. The one thing writing `miss` was the *embedding* call the semantic cache makes on the way to answering a request, which is not a request and which fires twice per missed one. So the panel divided hits by hits (plus, for a semantic Agent, some embedding calls), and an Agent that never embeds reported a perfect cache from its first hit onward. What you get instead: * **A real hit rate.** Every request an Agent with caching on now records what the cache did: served it, was consulted and had nothing, or was never eligible. Ineligible requests — a streamed response, a response-direction guardrail hook, a customer-managed encryption key, a caller the gateway cannot attribute to a token — are counted as **bypassed**, beside the rate and never inside it. Requests on an Agent with caching off record nothing and stay out of the denominator entirely. * **A semantic hit rate with its own denominator**, plus a count of the requests that asked for approximate matching and did not get it, **with the reason**. The commonest reason is worth knowing before you turn the feature on: **`pii_request_mode` and `pii_response_mode` both default to on, and either one keeps approximate matching off**, so an Agent created with the defaults and switched to `cache_mode: "semantic"` silently gets the exact cache and nothing else. The panel used to guess at this from "every hit was an exact one", which says nothing when there are no hits; it now counts it. * **Historical `miss` rows are corrected** to "no cache outcome" by migration `20261118`. They were all embedding rows. Counts for windows spanning 2026-09-08 therefore go **down**, and up in accuracy. The cache itself is unchanged — same keys, same scoping, same behaviour. Only what is recorded about it changed. If you want a number before you trust one, the measurement, its fixture and its caveats are written up in `docs/internal/ai-gateway-semantic-cache-measurement-2026-09.md`. ### A human can finally get an MCP token — device flow + connect packs **`POST /v1/oauth/device_code`, and the RFC 8628 `device_code` grant on `POST /v1/oauth/token`.** Until now the only way to obtain a resource-bound `tool` token was the OIDC workload-federation exchange: present an id\_token from a bound CI issuer. That is the right door for a build job and no door at all for a person on Claude Code, Claude Desktop or Cursor — which is why there were no connect instructions to give them. The flow: ask for a code, approve it in the console at `/ai-gateway/device`, poll for the token. It mints through the **same** exchange as the federation path, so a device token is indistinguishable from a federated one at the data plane — audience-bound to one MCP server, bound to one person, and confined to the mode you approved it in. * Codes last **15 minutes** and are single-use; a `device_code` mints **once**, and a second poll with it is refused as a replay. * Polling faster than the returned `interval` is answered `slow_down`, not with a token. * The approval screen shows the name the client gave itself and says plainly that it is **not verified** — approve only a code you generated yourself. * Everything the token can do is fixed by the approver, from their session: the workspace, the Live/Test mode, the MCP server and the person it names. The device supplies a server slug and a name for itself and nothing else it says is trusted. New page: [Connect Claude Code, Claude Desktop and Cursor](/ai-gateway/connect-mcp-clients) — the exact config block each client expects, and the flow you run. You copy the block into place yourself; there is no installer and no extension. ### Delegated OAuth for MCP upstreams **A third upstream-auth mode, `auth.mode: "delegated"`.** The person consents once through the upstream's own OAuth; KnoxCall envelope-encrypts their refresh token under your tenant key and mints a short-lived access token per call. The upstream applies its own authorization to the real person, and nothing in the MCP client's configuration holds a secret. The other two modes are unchanged. `secret` dials with one shared credential; `passthrough` forwards each caller's own and stores nothing. * Connect from the MCP server's page in the console. **There is no API to create a connection**, deliberately: consent has to be given by the person whose credential it is. `/v1/ai-gateway/mcp-servers/{id}/grants` lists connections and revokes them (one, or all), in all five SDKs; no response carries a token in any form. * Register `https://oauth.knoxcall.com/callback` as the redirect URI — the one KnoxCall publishes, the same for every tenant. * An empty `scopes` list is refused: most providers read it as "give this app your default grant". * A literal `client_secret` is refused (422). Pass `client_secret_ref` as `{{secret_id:}}`. * Changing a server's `auth`, or archiving it, revokes every connection on it. Revoking destroys the stored tokens rather than flagging them; it does not revoke KnoxCall's authorization at the provider. * Only the person who started a connection can finish it: the callback requires a KnoxCall session and refuses, audibly, if it is not the same user. Requires a token that names a person — see the identity entry below. ### MCP passthrough identity is bound to the token, not claimed by a header ⚠ Behaviour change **`X-KC-User` is no longer an identity on `/v1/mcp`.** On a `mode: "passthrough"` MCP server, the person a call is attributed to now comes only from a **subject bound into the `tool` token's capability scope**, fixed at mint and covered by the token's capability signature. It used to come from the `X-KC-User` request header, checked only for the existence of a matching row in your directory. So ```http theme={"dark"} X-KC-User: alice@acme X-KC-Upstream-Authorization: Bearer ``` was accepted, and Bob's action against Jira was recorded against Alice — in the one artifact an incident reads. The control reduced to "name any provisioned employee". **What changes for you.** A passthrough call made with a token that names nobody is refused with `401 passthrough_identity_unbound` (it used to be accepted as long as a header named someone). Nothing changes on `secret`-mode MCP servers, on `/v1/ai`, or for per-user *cost attribution* on the chat plane, where `X-KC-User` is still what it always was: a hint that decorates a usage row. **How to get a token that names a person.** Two paths, both of which prove the person before minting: * **OIDC exchange** — set `subject_claim` on the workload binding to the claim that names the user (for example `email`). The value is read out of an id\_token your IdP signed, resolved against your directory, and bound into the minted token. A claim naming someone you have not provisioned is refused rather than minted unbound. Leave it null for ordinary CI: a build job is not a person. * **Device-code grant** — for a human on Claude Code, Claude Desktop or Cursor. If you send `X-KC-User` anyway it is kept on the call record as a *claim*, clearly separate from the bound subject, and a disagreement is logged. It never decides anything. Two related corrections landed with it. A passthrough refusal is now audited as what it is — `mcp_identity_unbound` / `mcp_passthrough_refused` — instead of `mcp_upstream_refused` with `upstream_unreachable: true`, which wrote a false statement about *your* upstream every time one of our own access controls fired. And `mcp_subject` is a reserved capability-scope key: it cannot be hand-written into a binding's scope, because who a token speaks for is proven per exchange, never configured. ### One error envelope on the data plane ⚠ Behaviour change **Every refusal `/v1/ai/*` can return now arrives in one shape**, and it is documented — endpoint included — in the OpenAPI spec for the first time: ```json theme={"dark"} { "error": "budget_exceeded", "error_description": "Daily budget exceeded: $50.0031 ≥ $50", "code": "budget_exceeded" } ``` Three shapes used to answer this one URL and they disagreed about which field was the machine-readable one. The request pipeline sent `{error: "", error_description}`. The authentication layer, one middleware earlier on the same request, sent `{error: "Unauthorized", code, reason}` — the inversion. The MCP audience guard sent a third reading in which `error` was a code that was not the `code`. Nothing in the response said which layer had refused, so no client could read `error` without shape-sniffing, and this page's own guidance was to read the `X-Auth-Failure` header instead of the body. `error` and `code` now always carry the same identifier, so **a client that reads either keeps working**. What changes: * **`reason` is gone** from authentication and audience-guard refusals. Its string is now `error_description`, the field the pipeline refusals already used. * **`error` on an authentication refusal is the code** (`expired`, `ip_cidr_mismatch`, …) rather than the word `Unauthorized` or `Forbidden`. `X-Auth-Failure` is unchanged and still carries the same value. * The `/v1` **management** envelope (`{error: {type, message, request_id}}`) and the `/v1/mcp` **JSON-RPC** error object are deliberately untouched — different planes, different protocols. The mapping is on the [errors page](/ai-gateway/errors). ### A 429 now says when to come back **New.** `budget_exceeded` and `token_scope_limit_exceeded` carry `Retry-After`, and the number is exact rather than a heuristic: spend counters are keyed by UTC day and UTC month, so the header counts to that cap's actual rollover. Both data planes emit it — `/v1/ai` and the JSON-RPC `/v1/mcp` — because it rides on the shared refusal rather than on either plane's response code. Where the server cannot know the answer it sends **no header** rather than a guess. So a 429 without `Retry-After` means "back off on your own schedule", and the header is never `0` and never fractional. ### A stream that dies mid-flight says why **New.** A streamed call commits its status line with the first frame, so until now a provider reset, an early close or a detector fault reached you as a stream that simply *stopped* — the same clean EOF as a model that finished, with no code and nothing to retry on. An SSE response that fails now ends with a terminal frame carrying the same object as any other refusal: ``` event: error data: {"error":"upstream_stream_failed","error_description":"…","code":"upstream_stream_failed","request_id":"…"} ``` A stream that ends with neither this frame nor the provider's own terminal event is a transport truncation — retry it. ### The buffered 502 stopped echoing internal errors ⚠ Behaviour change **Fixed.** A failure in the buffered response path answered with the thrown error's own message. That path wraps the response detector stack, detokenization, usage accounting and a database INSERT, so a Postgres failure put connection strings, role names and column layout in a response reachable by anyone holding a phantom token — the exact leak the pipeline's own error boundary refuses by name forty lines below it. It now answers the generic sentence plus `request_id`; the detail goes to the operator's logs. Quote the request id. ### Usage & Cost tells the truth about what it cannot price **Fixed — read this if you have been reading the total.** The Usage tab used to add up its numbers in your browser. A model that is not in KnoxCall's pricebook records a cost of **\$0** with `cost_source: unpriced`, so that sum reported a confidently wrong bill: the total was an undercount and nothing said so. The page now warns when any request in the period could not be priced, **names the models**, and flags the rows that carry them. It also says the part that costs money: an unpriced model is the same reason a per-Agent budget set to `block` does not stop spend on it. ### Group usage by agent, provider or a cost-centre tag — and export it **New.** The breakdown is grouped **server-side**, on every dimension the FinOps exporter has had all along: person, team, agent, model, provider, or `tag:` from your Agents' cost-attribution tags. **CSV export** downloads exactly the cut on screen, from the same aggregator — so the file and the table cannot disagree. Two more things that were being computed and thrown away: people and teams are now shown by **name** rather than as a raw uuid, and a **cost-per-day chart** sits above the breakdown. ### Federate CI with OIDC, from the product **New.** Gateway → **Federation** creates and lists OIDC workload bindings, so a CI job exchanges its own id\_token for a short-lived KnoxCall capability token and you store no long-lived secret in your CI provider. Presets for GitHub Actions, GitLab CI, CircleCI and HCP Terraform fill in the issuer *and* the claim that issuer is identified by, and there is a copyable GitHub Actions snippet. The API has accepted these since PR-A5; there was no way to create or even see one without calling it by hand. Two refusals the form makes before the server does, because both are easier to fix while you are still looking at the field: * **A scope that narrows nothing is refused.** An absent list is read as *no restriction*, so a binding with an empty scope would mint a token reaching every provider credential you have configured, on any model, with any tool, with no cost cap. Name at least one allowlist or one cap. * **Conditions must pin the claim that identifies your workload.** On GitHub's issuer, `ref: refs/heads/main` is true for every repository on GitHub. Bindings are **tenant-wide** — the same list appears under every Gateway, and deleting one deletes it everywhere. The page says so. ### A token parked by impossible-travel detection now says so **Fixed.** When a Gateway's impossible-travel setting is `block` and a capability token is used from somewhere it could not have travelled to, KnoxCall parks it: its next call is refused until the holder re-authenticates. The token page did not show that. It looked healthy while every call it made came back 401 `step_up_required`, and the endpoint that clears the flag had existed all along. The page now shows a banner naming what tripped it and when, the recent security events behind it, and a **Clear step-up flag** button for a confirmed false positive — a shared machine key used from two regions, say. Clearing is audited against your account, and the confirmation says so: if the flag was a true positive, that record is who let the token back in. **`block` never revokes**, and the setting now says that too. A revoked token would turn a false-positive geolocation into an outage; a parked one costs its holder a re-authentication — which is exactly what someone holding only the stolen bearer value cannot do. ### Impossible-travel detection is reachable in the UI **Fixed.** `geo_velocity_action` is on Gateway → Settings (`off` / `warn` / `block`). The column and its validation both shipped earlier and nothing in the admin UI ever sent it, so every Gateway sat on the `off` default and the control could not be turned on without calling the API by hand. ### A Requests tab, and the protection counters on Usage **New.** Gateway → **Requests** is a per-request activity feed: when, which agent and model, the status, the latency, the tokens, the cost — and, for the first time on any screen, what the gateway's own controls did to that request. A blocked prompt, a request whose PII was tokenized, a response served from cache: each is visible as such, filterable, and linkable (the filter is in the URL). Gateway → **Usage** grows a Protection row above the breakdown: PII spans redacted, prompts blocked, cache hits and the estimated saving. These numbers were being recorded on every request and shown nowhere. Two details that are easy to get wrong and are not: * **Blocked prompts are counted from firewall events, not from metered requests.** A blocked request never reaches cost accounting, so it has no usage row at all — counting there would report the one number that matters as zero. * **An unpriced model reads as `unpriced`, never as `$0.0000`.** A model KnoxCall cannot price is a gap in the pricebook, not a free request — and the same gap is why a `block` budget does not bind on that model. **Aggregate and redacted, deliberately.** No prompt or response body reaches this view, and none is returned by the API behind it. These rows describe traffic that has already been through PII tokenization; a body here would be the one place in the product where the values the gateway exists to withhold come back. What a detector found is shown as its entity type (`EMAIL_ADDRESS`), never as the value. Paging is keyset on the request ledger, so pages never overlap or skip while new requests are still arriving. ### See what the response cache is actually doing, and clear it **New.** Agent → **Budgets & cache** now shows the cache's own numbers over the last 30 days: hit rate, exact hits, near hits, misses, bypassed requests, how many entries are stored and how large they are — plus a **Purge cache** button for the moment you change a system prompt or a model and a replayed answer would be stale. Three things it is careful about: * **A hit rate of nothing is not a hit rate of zero.** With no eligible traffic the panel says so, rather than printing 0% and sending you to debug a cache that has had no chance to work. * **The saving is an estimate, and says so.** A request served from cache never reached a provider, so what it would have cost cannot be known. The figure is this Agent's mean priced request cost over the same window times the number of hits, and it ignores models KnoxCall cannot price. * **A silent degrade is named.** An Agent set to `semantic` whose every hit was an exact one is told: semantic matching additionally needs an embedding model, a conversation id on the request, and reversible tokenization off in both directions, and without them it quietly falls back to exact matching. That was previously invisible. The counters come from the request ledger, not from the entry table — an entry row only knows it was stored, while the ledger records what happened to each request. ### Set up Cursor, Claude Code, Cline or Continue from the Agent page **New.** The Agent detail page has a **Connect** tab. Pick the tool you use, and KnoxCall mints a capability key scoped to that Agent and renders the exact files, environment variables and commands to apply — including the verification command, so you can prove the first call went through the gateway before you change anything else. Ten setup packs ship today: Cursor, Claude Code, Cline, Continue, the Anthropic and OpenAI SDKs for Node and Python, and two generic Anthropic- and OpenAI-compatible profiles. Which ones are marked **recommended** comes from the Agent's own provider, not from the name of its model. This is also the first supported way to obtain a **DPoP-bound** setup. The mint APIs have accepted `dpop_required` since they were written, and nothing generated the key pair a client needs in order to send a proof; the render endpoint generates one and writes it into the tool's own config. KnoxCall keeps the public half and the thumbprint — never the private key. Binding is opt-in and separate, because a token bound to a key pair the client cannot use is a token that 401s on every call: tools with no DPoP install path do not offer it, and the server refuses it for them. The rendered files carry the key in plaintext, so the request is a POST (never a GET, which would leave a live credential in browser history and in every proxy access log on the way), it is refused to platform operators who are not members of your tenant, and each render is audited. The key is shown once. ### Connection snippets are composed by the server **Fixed.** The env-var, curl and SDK snippets on the Agent page were built in your browser from a second copy of KnoxCall's provider table. The copy was held byte-identical to the real one by a CI guard, so it never drifted — but two copies of one table is one copy too many, and the browser had no business knowing the shape of a Bedrock invoke path. `GET /admin/ai-gateway/{gateway}/agents/{agent}` now returns `snippets`, built by the same module the quickstart uses, and the page renders them. No behaviour changes for you: the same bytes, from one place. The token in those snippets is a placeholder — the GET mints nothing. It is replaced with the real value only in the moment you issue a key, and only on that screen. *** ## 2026-09-07 ### `knoxcall ai` is a working golden path, in every SDK **New.** The `ai` command group grew from one sub-command to six, and the new five are in **all five** SDK CLIs: ```bash theme={"dark"} export ANTHROPIC_API_KEY=sk-ant-... knoxcall ai create-agent --name copilot --slug copilot --provider anthropic --secret-from-env ANTHROPIC_API_KEY knoxcall ai mint --agent ``` Two commands from an empty tenant to a capability token and an `agent_url` you can `curl`. `create-agent` escrows the key, picks or creates the gateway, and prints the command that follows; `gateways`, `agents` and `usage` fill in the rest. Everything takes flags, never positionals, so the surface is identical in all five languages. Three things it deliberately will not do: * **Take a provider key as a flag.** `--secret-from-env NAME` names an environment variable; there is no `--secret-value`. An argv value lands in shell history, `ps` output and the CI log line that echoes the command. * **Create an agent with no upstream.** The API accepts that and stores an agent whose first call fails; `create-agent` refuses instead, and says why. * **Guess which gateway you meant.** With several and no `--gateway`, it lists them and stops. ### `knoxcall ai import` ships in an artifact you can install **Fixed.** The LiteLLM importer lived in a `cli/` package that was never published, never in CI and never tested beyond one suite — so [Migrating from LiteLLM](/ai-gateway/migrate-from-litellm) documented a command that shipped nowhere. Install it and follow the page and you got `invalid choice: 'import'`. It now lives in the Node SDK CLI (`npm i -g @knoxcall/knoxcall`), and the flag is `--file`: ```bash theme={"dark"} knoxcall ai import --from litellm --file ./config.yaml # dry run knoxcall ai import --from litellm --file ./config.yaml --apply ``` `import` is the one `ai` sub-command that is Node-only, on purpose — it carries a hand-rolled YAML parser because the CLI ships zero runtime dependencies, and four more hand-ports of that parser would be four more ways for the migration plan to disagree with itself about your config. The page says so. ### `agent_url` is on every agent response **New.** `agent_url` — the data-plane base URL, `https://{tenant}.knoxcall.com/v1/ai/{slug}` — used to come back only from *create* and from fetching a single agent. It is now on the list rows and on `PATCH` as well, and every SDK declares it on its agent type. `PATCH` is the one that mattered: renaming an agent's slug *moves* that URL, and the response that made the change was the one response that did not tell you the new one. ### SDKs no longer enumerate the provider catalog **⚠ Behaviour change, TypeScript and Go only.** The Node SDK's `AIProviderId` was a six-value union while the server catalog held fourteen, so `provider: "groq"` was a *type error* for a value the API happily accepts. It is now `string`, with the current catalog in its doc comment; Go's `AIProviderIDs` is documented as a convenience list rather than a validation set, and both list all fourteen. Pass the string and read the server's 400, which names the valid set — the catalog grows server-side and an SDK-side enum can only ever be behind it. If you were pattern-matching exhaustively on `AIProviderId`, that no longer type- checks; narrow to your own union at your call site. ### The SDK READMEs no longer teach an agent that 502s **Docs fix, and the one worth reading.** All five SDK READMEs showed a `createAgent` example with no `provider`/`upstream_secret_id` and no `primary_route_id`. That call *succeeds* — and produces an agent with no upstream and no credential template, so the first `/v1/ai` request to it fails. The copy-paste starting point ended in a 502 on the one call the product exists to make. Every README now escrows a Secret first and passes `provider` + `upstream_secret_id`, and a CI guard reads all five so the snippet cannot regress. ### Your PII recognizers and your scanner now reach `/v1/mcp` **⚠ Behaviour change.** Two controls that governed `/v1/ai` and quietly did not govern `/v1/mcp`: * **Your own PII recognizers.** The MCP plane built one detector stack at boot and used it for every tenant, so it held the shipped built-ins only. The custom entity shapes you defined — or that a compliance pack installed — were redacted out of a chat completion and passed through **verbatim** in a tool result. An MCP server now takes a `pii_redact_policy_id`, exactly like an agent, and loads your recognizers per request through the same loader and the same short-TTL cache. With a policy attached the plane **fails closed**: if the policy does not resolve for your tenant, if a stored pattern will not compile, if a `presidio_*` recognizer cannot run in-process, or if your set was truncated at the 250 limit, the tool call is refused rather than served under a partially-applied policy. With no policy attached, a load failure degrades to the built-ins and logs — because you asked us to enforce nothing in particular. * **Your guardrail webhook.** An MCP server now takes the same five `guardrail_webhook_*` fields an agent does. `request` mode is offered the tool ARGUMENTS after redaction and before they leave; `response` mode is offered the tool RESULT before it is returned; a `block` verdict refuses the call. Unlike `/v1/ai` there is no streaming carve-out and no `guardrail_streaming_unsupported`: the MCP transport materialises every JSON-RPC message, so both directions are always inspectable. A `block` arrives as a JSON-RPC error on a 200, not as an HTTP 4xx — the MCP plane speaks JSON-RPC. Deleting a PII policy an **MCP server** still references is now a `409 policy_in_use`, alongside the existing agent rule. The FK is `ON DELETE SET NULL`, so without it the delete would have silently detached every bound server and turned their redaction off. **What we did NOT add, and why.** The MCP plane does not get the `pii_request_mode` / `pii_response_mode` split the chat plane gained on 2026-09-07. Those exist to fix a specific defect — request-side tokenization gated on a *response*-side flag — and that defect provably does not exist here: tool arguments and tool results are inspected by two independent reads of `pii_inspection`, neither of which consults the other. And `tokenize` / `detokenize` have no meaning on this plane at all: redaction is irreversible, there is no conversation and no token map, so a mode named `tokenize` would name a behaviour that cannot exist. `pii_inspection` stays one boolean because it is answering one question. ### MCP servers are Live or Test, like everything else **⚠ Behaviour change.** `ai_gateway_mcp_servers` was the last tenant-owned resource on either AI-gateway data plane with no Live/Test split. On `/v1/ai` the mode rides the agent's route and is pinned twice; an MCP server has no route, so `GET`/`PATCH`/`DELETE` and the data-plane lookup filtered on no mode at all — and a `kp_test_` capability token reached the **Live** MCP server, at which point KnoxCall decrypted the **Live** upstream credential and sent it on. What changes for you: * Every `/v1/ai-gateway/…/mcp-servers` call is now confined to the calling request's own space. A production key on `api.knoxcall.com` does not see a Test server, and a test key on `sandbox.knoxcall.com` does not see a Live one — a 404, not a 403, because the row is not yours to know about. * `sandbox` appears on every MCP server response. It is **read-only**: it comes from the mode of the request that created the server. Sending it in a create or patch body is ignored. * The same `slug` may now exist in both spaces, so your MCP client's `connect_url` does not change between Live and Test — which capability token you present decides which server answers. The uniqueness keys moved from `(tenant, name)` / `(tenant, slug)` to `(tenant, sandbox, name)` / `(tenant, sandbox, slug)`, so a test key creating `github` no longer collides with a Live `github` it cannot even see. * A `kp_test_` capability token against a Live MCP server is a 404. A request that reaches the MCP plane with no proven env at all is a 401 — "unknown" never resolves to Live. **Every existing MCP server is Live.** They were created before the partition existed and their `auth` references resolve Live secrets; nothing was silently moved into Test. If you want a Test twin, create it with a test API key on the sandbox host — the same slug is fine. ### A gateway budget is now a control, and it reaches `/v1/mcp` **⚠ Behaviour change.** `budget_daily_usd` and `budget_monthly_usd` on a **gateway** were writable from the console and the API since the gateway existed and were read by nothing. A gateway capped at \$50/day enforced no cap at all. They are enforced now, on **both** data planes: * `/v1/ai` answers `429 budget_exceeded` with the message prefixed `Gateway budget:` so you can tell it apart from the agent's own cap. * `/v1/mcp` refuses `tools/call` with the same reason. Discovery methods (`initialize`, `ping`, `tools/list`) are deliberately still served: a client refused at the handshake reports "could not connect" and you never see the cap that caused it. The new field is `budget_overage_action` on a gateway: `block` (refuse) or `warn` (serve and emit `X-Knox-AI-Budget-Pct` / `X-Knox-AI-Budget-Warning`). There is no `fallback` — that action swaps an *agent's* route and model, which a gateway does not have. **Existing gateways that already carried a budget were set to `warn`**, not `block`. Those numbers were typed in while the column enforced nothing, and turning them into a hard refusal during a deploy would be an outage we chose on your behalf. New gateways default to `block`. One PATCH turns an existing one on. The MCP plane also gained the H-32 refusal it was missing: a capability token carrying `max_cost_per_day_usd` or `max_tokens_per_day` is now **refused** at `/v1/mcp` rather than served. A tool call costs nothing and consumes no model tokens, so that counter would never move — serving it would grant unlimited use of a credential whose own signed grant says it is capped. ### A hard spend cap refuses a model nothing can price **⚠ Behaviour change.** An agent with `budget_overage_action: "block"` and a dollar cap is now refused with `403 budget_unpriced_model` when its model has no price in KnoxCall's pricebook. The cap could not bind on such a model: the call is metered at \$0, the counter never moves, and the dashboard shows the budget comfortably unspent while spend continues. That is permanent for `openai-compatible`, `azure-openai` and `ollama`, whose endpoints are yours and therefore structurally unpriceable. `warn` and `fallback` agents are unaffected — they never promised a hard stop. If you need `block` on an unpriceable provider, either add the model to the pricebook or switch the action to `warn` and alert on `cost_source = 'unpriced'` in your usage export. ### A leaked system-prompt canary is blocked, not just noted **⚠ Behaviour change.** When a firewall policy is set to `block` and the provider's response echoes the system-prompt canary — proof that the prompt in that request extracted your system prompt — the response is now refused with `400 canary_leak` instead of being served with a 200. The marker itself never reached you either way; that part was already correct. What was wrong was the verdict: `ai_gateway_usage.firewall_outcome` recorded `warn` for a confirmed extraction, so a firewall report said the control held. Both the buffered and the streaming path now record the policy's real action. On a streamed response the bytes have already left when the leak is detected, so there the change is the record and the alert, not a refusal. Policies set to `warn` are unchanged. ### PII policies and recognizers are on the API **New.** `/v1/ai-gateway/pii-policies` and `/v1/ai-gateway/pii-recognizers` now carry full CRUD, plus a side-effect-free pattern tester at `POST /v1/ai-gateway/pii-recognizers/test`. They are in all five SDKs, the Postman collection and the OpenAPI spec. Why it matters: `pii_redact_policy_id` has always been accepted on `POST …/agents`, but the only way to *obtain* a policy id was to click through the console. A HIPAA-shaped setup — define your own entity shapes, bundle them, attach the bundle to an agent — is now scriptable end to end from an API key. There is a worked example in [PII redaction](/ai-gateway/pii-redaction). ### Three PII changes that used to succeed quietly are now refused **⚠ Behaviour change.** All three apply on the console as well as the API, because both surfaces call the same code. * **A `recognizer_ids` entry you do not own is a `400 recognizer_not_found`.** It used to be stored. It was never a cross-tenant read — the data plane resolves it to nothing — which is exactly the problem: the policy listed three recognizers and ran zero, and nothing said so until the PII reached the provider. * **Deleting a policy an agent still uses is a `409 policy_in_use`.** It used to succeed and silently detach every bound agent, turning redaction off for each of them. Detach the policy from the agents first. * **Deleting a recognizer a policy still lists is a `409 recognizer_in_use`.** Remove it from each policy first. We refuse rather than dropping the id for you, because an empty `recognizer_ids` means *every* enabled recognizer — so quietly removing the last id would widen the policy, not shrink it. If you script against these, the failures are visible ones: a refusal you can see beats a policy that looks armed and is not. ### The Presidio analyzer URL goes through the SSRF chokepoint **⚠ Behaviour change, security.** If you point the gateway at your own [Microsoft Presidio](/ai-gateway/pii-redaction) sidecar (Settings → Integrations → *AI Gateway PII analyzer*), that URL is now validated the way every other tenant-supplied destination on this platform is: the hostname is resolved, every returned address is checked, and the request is sent to the address we checked rather than to whatever the name resolves to a moment later. Why it matters: the analyzer receives **the prompt text being inspected**. Before this, the URL was dialled with an unvalidated HTTP client, so a value pointing at a private or link-local address — `http://169.254.169.254/`, `http://127.0.0.1`, or a hostname that resolves to one — made the gateway send prompt text there. What you may notice: * On the **cloud** service, an analyzer URL must be a publicly-routable `http(s)` host. Saving a private-network URL now fails with `presidio_url_blocked`, and an existing row with such a URL stops being used: the detector stack falls back to its built-in regex/checksum tiers (degraded, not broken) and logs the refusal. * On a **self-hosted** deployment, a sidecar on your own LAN is still supported. Tick **“Analyzer is on this deployment’s private network”** on the same integration. It is off by default, it is ignored on the cloud service, and it never permits link-local addresses (`169.254.0.0/16`, `fe80::/10`) — the cloud metadata range is not a network anyone runs a sidecar on. * Redirects and non-`http(s)` schemes on the analyzer URL are refused. ### Failed AI and MCP authentications are audited **Additive.** Every refusal from the gateway's authentication layer now writes an `ai_gateway.request_denied` row to your audit log, on **both** `/v1/ai` and `/v1/mcp`. Previously only refusals from inside the request pipeline (firewall, budget, model policy) were audited, and everything the auth layer rejected — expired tokens, revoked tokens, a replayed one-shot token, a token used from outside its `ip_cidrs` or its `valid_window`, a DPoP failure, an MCP token presented to the wrong resource — produced a status code and no record at all. A credential-stuffing run against your gateway was invisible in the audit log. Filter on `details.reason` to see them: `auth_token_rejected`, `auth_scope_denied`, `auth_tenant_unavailable`, `auth_agent_unavailable`, `auth_oneshot_replay`, `mcp_audience_denied`. `details.code` carries the exact failure and `details.plane` is `ai` or `mcp`. Two deliberate exclusions: a request presenting **no token, or a token that matches no record**, writes nothing — there is no workspace to attribute it to, and an unauthenticated request must not be able to write to your audit log. A **paused agent or gateway** writes nothing either, because that is configuration state and it would fire on every request for as long as the pause lasts. ### PII redaction is two settings now, and three parallel paths that leaked are closed **⚠ Behaviour change, security.** The core promise — a detected entity does not reach the provider — held on the default path and failed on three others. All three are closed, and the single boolean that caused the first one is now two explicit settings. **`pii_request_mode` and `pii_response_mode` replace `pii_detokenize_response`.** One flag was answering two questions, and the one it named was the wrong one: request-side tokenization was gated on `pii_detokenize_response`, a **response** toggle. An agent with a PII policy attached and `pii_detokenize_response: false` loaded its recognizers, validated them, refused on a degraded policy — and then sent the prompt to the provider verbatim. | Setting | Values | Default | | - | - | - | | `pii_request_mode` | `tokenize` \| `off` | `tokenize` | | `pii_response_mode` | `detokenize` \| `redact` | `detokenize` | Existing agents were migrated to today's response behaviour (`true` → `detokenize`, `false` → `redact`) and to `pii_request_mode: "tokenize"` **including the agents that had detokenize off** — those are exactly the ones that were leaking. If one of your agents deliberately forwarded prompts untouched, set `pii_request_mode: "off"` on it explicitly. `pii_detokenize_response` is still accepted on create and update, and is now a read-only mirror of `pii_response_mode == "detokenize"` on reads; sending both with contradictory values is a 400. **Streaming on a shape with no mid-stream redactor is refused.** KnoxCall ships a hold-back rewriter for the Anthropic, OpenAI and Gemini SSE shapes. Cohere's native SSE shape has none, and Bedrock streams length-prefixed `application/vnd.amazon.eventstream` binary frames rather than SSE at all. The handler used to stream those through untouched — no response-side redaction, no detokenization, no canary scan, no refusal. A streamed request on a `cohere` or `bedrock` agent is now **400 `streaming_redaction_unsupported`**. Send it without `stream: true` (buffered responses are fully redacted), or set `streaming_enabled: false` on the agent. **A body the tokenizer cannot read is refused.** `tokenizeRequestBody` returned the RAW body with a 200 for anything that would not `JSON.parse`. On an agent with `pii_request_mode: "tokenize"` that is now **400 `pii_unscannable_body`**. "We found no personal data" and "we could not look" are different answers. **More of the prompt is scanned.** The shared prompt-shape enumeration the tokenizer and the firewall both read now covers Cohere `message`, `preamble` and `chat_history[].message` (on Cohere, `message` *is* the prompt — those agents were sending every turn upstream in the clear), the OpenAI Responses `instructions` field, RAG grounding `documents[]`, and **tool descriptions** on all four declaration shapes. Tool *schemas* are still not rewritten, deliberately. **A configured Presidio analyzer that cannot run is announced.** `getPresidioDetector` answered `null` to two different questions — "no analyzer configured" and "an analyzer IS configured and this deployment will not use it" (URL refused by the egress check, or circuit breaker open after five consecutive failures). The second degraded to the Aho-Corasick + regex tiers, served 200, and recorded nothing anywhere. Now: with a PII policy attached, `503 pii_policy_degraded`; with no policy, served with `X-Knox-AI-Pii-Degraded: presidio_` and a `detector_unavailable` row in the PII events ledger. Having no analyzer configured is still silent, because nothing was asked for. **Semantic caching follows the new columns.** "Reversible tokenization is active" is now two columns, and the one that actually mints tokens is `pii_request_mode`. Either half keeps semantic matching off (exact caching is unaffected). Because the migration set `pii_request_mode: "tokenize"` on every tenant-owned agent, an agent that had `pii_detokenize_response: false` **and** `cache_mode: "semantic"` drops to exact caching. That is the safe direction and the reason the guard exists: those bodies are full of conversation-scoped `KC_*` tokens. Set `pii_request_mode: "off"` to get semantic matching back. See [PII redaction](/ai-gateway/pii-redaction#the-two-directions). *** ## 2026-09-03 ### Credentials in a URL are refused **⚠ Behaviour change.** A `guardrail_webhook_url` on an agent, or an `upstream_url` on an MCP server, may no longer carry userinfo — `https://user:pass@host/…` is rejected at create and at update. A credential written into a URL is stored in plaintext in a column nothing encrypts, and it leaks into every log line, audit row and error message that echoes the URL. Any userinfo already stored is stripped from API read responses. Move the credential into a Secret and reference it from the auth headers instead. *** ## 2026-09-02 ### A route requiring a signature is refused, not silently unsigned **⚠ Behaviour change.** `/v1/ai` cannot compute a rolling URL signature, so an agent pointed at a route with `require_signature` used to reach the upstream without one. It is now refused with **503 `route_signature_unsupported`**, and a failover candidate with the same setting is skipped rather than tried, with the skip written to the audit row. Same shape as `route_field_actions_unsupported` (2026-08-27): the plane refuses what it cannot honour rather than serving a weaker version of it. ### Responses carry KnoxCall's own document-isolation headers **⚠ Behaviour change.** A response served on a KnoxCall host now carries a sandboxing `Content-Security-Policy`, `X-Frame-Options: DENY` and `X-Content-Type-Options: nosniff` written by the gateway — and the **upstream's** values for those headers are dropped rather than relayed. The body is tenant-controlled bytes on our origin; the upstream does not get to set the policy the browser honours there. If you were reading an upstream's CSP through the gateway, you will now see KnoxCall's instead. ### Upstream `Set-Cookie` is never relayed **⚠ Behaviour change.** `Set-Cookie` and `Set-Cookie2` from the upstream are dropped on every `/v1/ai` path — cache hit, buffered and streamed alike — and the full RFC 7230 hop-by-hop set is stripped rather than just `transfer-encoding`. An upstream cannot set a cookie on a KnoxCall origin. Multi-valued response headers now relay as a list instead of being collapsed. *** ## 2026-09-01 ### Phantom tokens move to their own `kp_` prefix **⚠⚠ Breaking change, no compatibility window. Deployed to production 2026-09-02.** Phantom tokens — the AI data-plane credential — were `kc_`-prefixed, which is the *OAuth 2.1 access token* family. They are now **`kp_`**: ```text theme={"dark"} kp___<32hex>_ e.g. kp_live_a_…, kp_test_t_… ``` **A `kc_`-prefixed phantom token no longer parses. There is no dual-parse window. Re-mint every AI data-plane token.** A token issued before this change fails at the edge; the fix is a new mint, and nothing else recovers it. Why it could not stay: `src/client-api/auth.ts` classifies a bearer credential by `kc_`, so a phantom token presented to any `/v1` client-API route was parsed as an OAuth token — which requires four underscore-separated segments where this format has five — and refused as `401 invalid_token: Malformed access token`. The credential was valid; it had reached the wrong plane, and the error said nothing of the sort. Two credential families sharing a prefix is a type-confusion hazard whether or not a particular caller trips it today. `kp_` is now disjoint from `kc_` (OAuth access), `kr_` (OAuth refresh), `tk_` (tenant API key) and `AKE` (enterprise access key), and it has been added to the never-forward list that stops a KnoxCall credential being relayed to a third-party MCP server — see [MCP gateway](/ai-gateway/mcp-gateway#passthrough--each-user-acts-as-themselves). This landed while the AI gateway had no live tenant traffic, which is why no customer integration is known to have broken. That was luck, not process. This changelog entry, and the docs-truth guard that now requires one for any change under `src/ai-gateway`, exist so the next prefix change is announced before it ships. *** ## 2026-08-31 ### Linked-secret domain violations are audited on both tenants When a shared secret's domain lock refuses an upstream, a `secret_share.domain_violation` audit row is now written for **both** the tenant that used the secret and the tenant that shared it — from `/v1/ai` and, newly, from the MCP plane. The lender could previously not see that their lock had fired. ### Test-mode PII detections stop firing live compliance alerts PII detection events now record which data space they came from. A detection in Test no longer raises the Live HIPAA / SOC 2 compliance alert or consumes its cooldown, so a sandbox experiment cannot mask a real Live detection. Expect fewer, truer alerts. ### Federated mints are audited Every token minted through `/v1/oauth/token` now writes an `ai_gateway.token_mint` audit row tagged `via: oidc_federation`. This file previously wrote no audit row of any kind, so a burst of federated data-plane credentials was invisible to the privileged-mint velocity monitor. *** ## 2026-08-30 ### Minted tokens expire by default **⚠ Behaviour change.** A mint that named no expiry produced a token that **never expired**. Every mint path now shares one implementation: an omitted expiry means **30 days**, and any requested TTL is clamped to between **60 seconds** and **90 days**. The default is deliberately not the ceiling — long enough that a CI key is not a weekly chore, short enough that a key leaked from a repo scraped last quarter is already dead. If you rely on a never-expiring AI token, you do not have one any more. Re-mint with an explicit `expires_in_seconds`, up to the 90-day cap. ### Archiving an agent or gateway disables its orphaned routes **⚠ Behaviour change.** Archiving left the underlying `routes` rows enabled, so the upstream stayed reachable through a gateway object you had archived. Archive now disables the routes it orphans — scoped to the data space you archived in, so archiving a sandbox agent does not disable the Live route (that scoping was itself a same-week fix; between 2026-08-30 and 2026-08-31 it was not scoped). ### CSV exports are quoted and formula-neutralized **⚠ Behaviour change.** Usage-export and compliance-bundle CSVs now quote every cell and prefix any cell beginning `=`, `+`, `-` or `@` with a single quote, so a model name or tag cannot execute as a spreadsheet formula when the file is opened. A strict downstream parser that assumed unquoted cells needs updating. ### A response containing an injected credential is never cached If a response body contains a resolved injected credential, it is not written to the response cache. Those calls will never register a cache hit. ### Guardrail webhooks receive a scrubbed body **⚠ Behaviour change.** The guardrail webhook payload now decompresses the upstream response before scanning it, removes any resolved secret value found in it, and sends an **empty** body when the content encoding cannot be decoded — a body that cannot be inspected is not forwarded to your endpoint. A webhook that parsed the raw compressed bytes needs updating; see [Guardrail webhook](/ai-gateway/guardrail-webhook). *** ## 2026-08-28 ### Semantic-cache embedding calls are metered **⚠ Behaviour change, cost.** The semantic cache's embedding calls were real provider spend and were recorded nowhere. They now produce their own usage rows, count against the agent's budget, and count against a token's signed daily cost and token caps. Expect your usage row count and your metered spend to rise on agents using `cache_mode: "semantic"` — the money was always being spent; only the accounting changed. A budget can now trip on cache traffic. Under a signed `max_cost_per_day_usd`, an embedding on a model that is **not in the pricebook** is not purchased at all — the semantic cache silently degrades to exact matching rather than making a spend the cap cannot count. ### A suspended workspace is refused on every plane **⚠ Behaviour change.** A phantom token belonging to a workspace that is not servable is refused with **403 `tenant_unavailable`** on `/v1/ai` and `/v1/mcp`, and `/v1/oauth/token` answers `invalid_grant`. ### `Cookie` is never forwarded upstream **⚠ Behaviour change.** A `Cookie` header on a request to the data plane is dropped rather than relayed to the provider, and a KnoxCall `Authorization`, `DPoP` or `x-api-key` credential is consumed by the gateway rather than passed through. A browser-originated call that relied on its cookies reaching the upstream will see them gone. *** ## 2026-08-27 ### A paused agent or gateway refuses, on both planes **⚠ Behaviour change.** A paused or archived agent — or one whose gateway is paused or archived — is refused with **503 `agent_not_active`** / **`gateway_not_active`** on `/v1/ai`, and, newly, on `/v1/mcp` as JSON-RPC `-32603`. The MCP plane previously served traffic for a paused gateway. ### A route with Relay field actions is refused **⚠ Behaviour change.** `/v1/ai` cannot apply a route's Relay field actions, so an agent pointed at such a route is refused with **503 `route_field_actions_unsupported`** rather than served with those actions silently skipped. A failover candidate with them is skipped and audited. ### Batch spend counts against your budget **⚠ Behaviour change.** Batch spend was metered but not *charged*: it appeared in usage and never moved a budget. It now counts against the agent's budget and the token's signed daily caps, so a batch can push you into `budget_exceeded` or `token_scope_limit_exceeded`. *** ## 2026-08-26 ### An unenforceable budget refuses rather than serving unmetered **⚠ Behaviour change.** When the cross-worker spend counter is unusable, the gateway can no longer know what has been spent. It now sets **`X-Knox-AI-Budget-Unmetered: 1`** on the response, and on an agent whose `budget_overage_action` is `block` it refuses with **503 `budget_unenforceable`** instead of serving a request it cannot count. `warn` and `fallback` agents are still served, unmetered, with `X-Knox-AI-Budget-Pct` suppressed — a percentage from a frozen counter reads `0.0%`, which is the lie in header form. A counter holding a non-numeric value now raises instead of reading as zero. ### DPoP `htu` is derived from the canonical URL **⚠ Behaviour change.** The `htu` claim in a DPoP proof is now checked against the canonical public request URL rather than the raw `X-Forwarded-*` / `Host` headers. Sign your proof against the URL you actually call. A client behind a proxy that rewrites those headers may need to change what it signs. *** ## 2026-08-25 ### Batch spend is metered, once, at the batch rate Provider batch APIs bill at a discount, and batch requests were being metered at the interactive rate — or, on some shapes, not at all. Batch usage is now metered exactly once, at the batch rate. (Charging it against the budget came two days later; see 2026-08-27.) ### Per-user MCP credential passthrough An MCP server can be set to `auth.mode: "passthrough"`, where each caller supplies their own upstream credential per request and KnoxCall stores nothing. `X-KC-User` is required and is resolved against your directory; a KnoxCall credential in the header is refused; connections are never shared between users. See [MCP gateway](/ai-gateway/mcp-gateway#passthrough--each-user-acts-as-themselves). ### A residency denial names the destination, not the pin A data-residency refusal recorded the agent's configured region rather than the region the request would have reached, which is the one an auditor needs. ### Extended-thinking and refusal text are redacted **⚠ Behaviour change, security.** The streaming redactor scanned a model's `content` and nothing else. Two other fields carry model-visible text and were going out unscanned: * **Anthropic `thinking_delta`** — with extended thinking enabled, the model's reasoning streams in its own delta type, and reasoning quotes the prompt constantly. An SSN in a thinking block reached the client verbatim **and produced no detection event**, so it appeared in no evidence row and fired no canary alert. Because the buffered path scans the whole response body, `stream: true` was effectively an opt-out from redaction for this content. * **OpenAI `delta.refusal`** — when a model declines, its explanation streams in `refusal`, not `content`, and a refusal routinely quotes back what it is refusing. Both are now redacted, each in its own hold-back window. See [what in a stream is scanned](/ai-gateway/streaming-guarantees#what-in-a-stream-is-scanned). If you replay Anthropic thinking blocks verbatim on a later turn, note that redacting their text invalidates the block's `signature` — re-prompt instead. Streamed **tool-call arguments** are still not scanned; that is stated explicitly on the streaming page rather than left to be discovered. ### MCP sessions are scoped to the presenting token **⚠ Behaviour change.** The upstream MCP session pool was keyed on the client-supplied `mcp-session-id` header. Two different phantom tokens sending the same value — including the common case of sending none at all — shared one upstream connection. The pool key now leads with the verified token, so **each token gets its own upstream MCP session**. What you may notice: slightly more upstream connections, and one extra initialization handshake the first time each token calls. If your upstream MCP server keeps per-session state, that state is now per-token rather than shared. A single token holds at most **4** concurrent sessions per server per environment; opening a fifth closes the least recently used. ### `server_type: 'collection'` MCP servers are refused at creation Creating an MCP server backed by a route collection was accepted by the API and then failed on every call, because the feature was never built. It is now refused at create with a clear message. An existing collection row answers **501** (it previously produced a generic 502 that pointed you at an upstream nothing had dialled). ### `resources/*` and `prompts/*` on the MCP plane are documented as refused No code change — the gateway governs **tools**, and has always answered method-not-found (`-32601`) for resource and prompt methods. It was not written down anywhere. See [MCP gateway](/ai-gateway/mcp-gateway). *** ## 2026-08-24 ### Every content block in a streamed response is redacted **⚠ Behaviour change, security.** The OpenAI streaming redactor inspected `choices[0]` only. A request with `n > 1` streamed its second and later completions **unredacted** — the control was running and looking at the wrong element. Gemini's redactor had the same defect one field over (`candidates[0].parts[0]`), and additionally missed a candidate's second text part. All completions are now extracted and redacted, each with its own hold-back window. If you use `n > 1` with PII redaction on, expect redaction to appear in completions where it previously did not. ### `X-KC-Team` is honoured **⚠ Behaviour change.** Sending `X-KC-Team` previously cleared the team on the usage row — the opposite of what the header is for — so calls that named a cost centre were the ones attributed to nobody. The header is now resolved against your directory and lands on `attributed_team_id`. See [Budgets & FinOps](/ai-gateway/budgets-finops#the-x-kc-team-override). ### Rate limits on all three data planes `/v1/ai`, `/v1/mcp` and `/v1/oauth/token` register ahead of the proxy's catch-all and so had **no request-rate ceiling of any kind**. All three now enforce cross-worker sliding windows — see [Limits](/ai-gateway/limits#request-rate). The ceilings are generous abuse floors; a normal integration will not meet one. ### Provider credentials are actually injected **⚠ Behaviour change, and the big one.** The data plane did not perform route secret injection at all: every `/v1/ai` upstream call went out with **no provider credential**, so it 401'd against any real provider. The route's `inject_headers_json` / `inject_body_json` are now rendered through the same secret-resolution chokepoint the main proxy uses, on the primary route, every failover route, the output-schema retry, and the semantic-cache embedder. If you had worked around this by putting a real provider key in your own client, stop — the gateway now injects one too and the upstream will see whichever it resolves. ### Live/Test confinement is enforced **⚠ Behaviour change.** A phantom token's environment segment (`…_live_` / `…_test_`) was parsed and discarded, so a Test token drove Live routes. The segment is now part of the route lookup on the primary and every failover route, and a token whose environment cannot be proven is refused. A Test token that had been reaching a Live route will now get a 404 for that agent's route. (Phantom tokens carried a `kc_` prefix at the time; they moved to `kp_` on 2026-09-01 — see below.) ### Firewall policies can be created, and `block` is reachable No product surface could create a row in the firewall-policy table, so with no policy the evaluator capped every outcome at `warn` — **`block` was unreachable for every tenant** while the UI presented it as configurable. Policies are now creatable from `/v1`, the admin API and the gateway-creation seeder, and a `block` policy blocks. See [Firewall](/ai-gateway/firewall). ### Tenant PII recognizers are read by the data plane Custom recognizers and compliance-pack recognizers were written and never read. They now compile into the detector stack on the outbound, streaming and buffered paths. An attached policy that cannot be resolved, or that holds a pattern that will not compile, now **refuses the request** rather than forwarding unredacted. ### The MCP control plane exists `/v1/ai-gateway/.../mcp-servers` CRUD, tool management, an admin twin and an MCP tab. Before this, creating an MCP server required a raw database insert. ### MCP upstream egress and credentials go through the chokepoints **Security.** Tenant `upstream_url` values are now resolved-and-pinned like every other tenant-supplied destination, and upstream auth **must** be a `{{secret_id:…}}` reference — a cleartext credential in the `auth` column is refused rather than forwarded. ### Streaming hold-back leaked at the boundary **⚠ Behaviour change, security.** The hold-back FSM deferred a match ending inside its tail but emitted everything before that same index, so an entity *straddling* the boundary had its head emitted in the clear. On a typical one-word-per-frame stream that was nearly every entity. Separately, patterns that do not match until complete (JWTs, provider API keys) had their heads emitted at the default window. Both are fixed; the minimum hold-back window is now 96 characters, raised from whatever a tenant had configured below it. ### Error boundary and fail-closed policy loads A database error inside the pipeline used to reject an async middleware promise that Express 4 ignores — leaving the request open indefinitely. Every path now answers. A firewall or PII policy that cannot be loaded refuses the request (503) instead of proceeding with the control off. ### Guardrail quality is measured A labelled corpus and a recall ratchet now run on every build, so a weakened detector fails CI. Published figures live in [PII redaction](/ai-gateway/pii-redaction). *** ## 2026-08-12 ### Usage rollup is scheduled `ai_gateway_usage_daily` had no writer registered anywhere, so it was empty — and everything reading it (the spend-spike check, console usage) silently saw nothing. It now runs hourly on a single scheduler process. *** ## 2026-08-08 ### H-31 — the response cache is keyed on the caller, not just the conversation **⚠ Behaviour change, security.** The cache's isolation boundary was `x-kc-conversation-id`, a plain client header with no binding to the caller's token. Two users in one tenant sending the same body **with no conversation header** both scoped to the empty string and could serve each other's responses. Stored response headers were replayed wholesale, `Set-Cookie` included. The cache key now folds in the **phantom token id** — the only caller identity the gateway verifies — and the key prefix moved `v1` → `v2` so nothing written under the weaker assumption can ever be matched again. A request the gateway cannot attribute to a token does not read from or write to the cache at all. What you may notice: **every cache entry written before 2026-08-08 was abandoned**, so hit rates started from zero that day. Two clients that were (incorrectly) sharing a cache entry no longer do. ### H-32 — capability-token scope is enforced **⚠ Behaviour change, security.** A token's `providers`, `models`, `tools`, `egress_routes`, `max_cost_per_day_usd` and `max_tokens_per_day` were HMAC-signed and **never checked** — only `valid_window` and `ip_cidrs` were. The admin UI rendered the unenforced fields as though they were restrictions. They are now enforced, after model policy resolves. Three fail-open cases inside the fix were corrected in the same change: a malformed scope **denies** rather than being ignored; a present-but-empty list is malformed rather than "unrestricted"; and a cost cap **refuses** a model with no price rather than metering it as free. If you minted tokens with scope fields set, expect them to start taking effect — this is the change most likely to turn a working call into a 403. Check `X-Auth-Failure` and the `error` code on any new refusal before assuming an outage. ### H-33 — buffered responses get response-side redaction **⚠ Behaviour change, security.** Non-streaming responses bypassed all response-side PII redaction and detokenization, so a client could opt out of redaction simply by omitting `stream: true`. Both branches now run the same stack. *** ## 2026-07-09 ### Semantic caching, conversation-scoped Semantic (embedding-similarity) cache matching was re-enabled with a hard conversation scope, so an approximate match can only occur within one conversation. See [Response caching](/ai-gateway/quickstart#response-caching). ### Impossible-travel step-up `geo_velocity_action = 'block'` flags a token for **step-up** on an anomalous location change rather than revoking it: the token's next use is refused with `step_up_required` until it is re-authenticated or the flag is cleared. See [Tokens & DPoP](/ai-gateway/tokens-and-dpop#impossible-travel-detection-and-step-up). *** ## Earlier The gateway was built as roughly thirty sequential changes through May–July 2026: the phantom-token format and DPoP binding, the PII detector stack and hold-back streaming redactor, the prompt firewall and canary tokens, the pricebook, budgets and FinOps exports, OIDC workload federation at `/v1/oauth/token`, the response cache, and the MCP gateway. Those predate this changelog; the pages in this section describe what they do today rather than the order they arrived in. # Compliance Packs Source: https://docs.knoxcall.com/ai-gateway/compliance-packs One-click HIPAA, PCI-DSS, GDPR, and SOC 2 packs that install PII recognizers and audit-log alert rules — with honest install reporting that never silently drops a rule. # Compliance Packs A compliance pack installs, in one operation, the recognizers and alert rules for a regulatory regime. Available packs: `hipaa-safe-harbor`, `pci-dss`, `gdpr`, `soc2`. ## What a pack installs 1. **PII recognizers** — entity-type patterns added to the [detector stack](/ai-gateway/pii-redaction) (for example, HIPAA Safe Harbor's 18 identifiers). 2. **Alert rules** — `ai_gateway_event` alerts that fire on specific conditions, including PII-event conditions counted from the `ai_gateway_pii_events` ledger. For example, HIPAA installs a rule for *"an SSN appears in a response"* and a *"canary echoed"* rule. ## Honest install reporting The installer **never silently drops a rule**. If a pack contains an alert condition the current engine can't express, the install result reports it explicitly in an `alerts_skipped` field rather than quietly discarding it — so what you installed is always exactly what you can see. PII-event conditions (entity type + direction) are translated into real evaluator conditions, so the richer rules install instead of vanishing. Install a pack from the **Compliance tab** on the gateway detail page, or via the Packs API. A dry-run shows exactly what would be created before you commit. ## Evidence exports The Compliance tab can produce a **per-request evidence bundle** — what was redacted or blocked, by which policy, for a given period — as an exportable artifact. This is the compliance evidence auditors ask for and that most gateways can't produce. ## Related audit actions Packs listen on audit actions the gateway already emits, so alerts light up with no extra wiring: | Audit action | Fires when | | - | - | | `ai_gateway.canary_leak` | A [canary token](/ai-gateway/firewall) was echoed by the model. | | PII-event conditions | A configured entity type appears in a request or response (from the PII ledger). | Right-to-be-forgotten (RTBF) and retention automation for the PII ledger and token map are part of the compliance roadmap; the recognizer + alert-rule install and evidence export described here are available today. # Connect Claude Code, Claude Desktop and Cursor Source: https://docs.knoxcall.com/ai-gateway/connect-mcp-clients Get an MCP token as a person — not as a CI job — and point your client at a KnoxCall-governed MCP server. # Connect your MCP client Every tool call your client makes through KnoxCall reaches the upstream with your identity on it: the tool allowlist, the prompt-injection firewall and PII redaction all run first, and the call is recorded against **you** rather than against a shared key. This page is the whole flow for the three clients KnoxCall documents. One page rather than three, because the token flow is identical and only the file location differs — a page per client is three places for the same instructions to drift. You copy the config block into place yourself. KnoxCall does not install anything into your editor and there is no extension or protocol handler. ## 1. Get a token, as yourself An MCP token has to name a person. The workload-federation exchange (`/v1/oauth/token`) mints tokens for CI jobs from a signed id\_token, which is the wrong shape for a human at a laptop — so there is a device flow. **If your client speaks RFC 8628** (device authorization), point it at KnoxCall and it will show you a code. **If it does not**, run the flow yourself: ```bash theme={"dark"} # 1. Ask for a code. `mcp_server` is the slug of the server you want — # your admin console shows it next to each MCP server. curl -s https://api.knoxcall.com/v1/oauth/device_code \ -d mcp_server=jira \ -d client_name="my laptop" ``` ```json theme={"dark"} { "device_code": "…", "user_code": "H4T9-KP2M", "verification_uri": "https://app.knoxcall.com/ai-gateway/device", "verification_uri_complete": "https://app.knoxcall.com/ai-gateway/device?user_code=H4T9-KP2M", "expires_in": 900, "interval": 5 } ``` Open `verification_uri_complete`, check that the screen names the MCP server and the workspace you expected, and approve. Codes last **15 minutes** and can be used **once**. ```bash theme={"dark"} # 2. Poll for the token. Wait `interval` seconds between polls — polling faster # is answered with `slow_down`, not with a token. curl -s https://api.knoxcall.com/v1/oauth/token \ -d grant_type=urn:ietf:params:oauth:grant-type:device_code \ -d device_code=… ``` While you have not approved yet, that returns `authorization_pending`. Once you have, it returns the token — **once**. A second poll with the same `device_code` is refused as a replay. ```json theme={"dark"} { "access_token": "kp_live_t_…", "token_type": "Bearer", "expires_in": 900 } ``` **Approve only a code you just generated yourself.** The approval screen shows the name the client gave itself, and that name is not verified — anyone can start a flow and ask you to approve it. Approving one hands *that* device a token that acts as you. ## 2. Point your client at the server All three clients read the same `mcpServers` map. Replace the token with the one you just obtained, and the URL with the **Connect URL** shown on the MCP server's page in your admin console. `.mcp.json` in your project root (shared with your team), or `~/.claude.json` for just you: ```json theme={"dark"} { "mcpServers": { "jira": { "type": "http", "url": "https://api.knoxcall.com/v1/mcp/jira", "headers": { "Authorization": "Bearer kp_live_t_" } } } } ``` `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, `%APPDATA%\Claude\claude_desktop_config.json` on Windows: ```json theme={"dark"} { "mcpServers": { "jira": { "type": "http", "url": "https://api.knoxcall.com/v1/mcp/jira", "headers": { "Authorization": "Bearer kp_live_t_" } } } } ``` Restart Claude Desktop after editing the file. `.cursor/mcp.json` in your project, or `~/.cursor/mcp.json` for every project: ```json theme={"dark"} { "mcpServers": { "jira": { "type": "http", "url": "https://api.knoxcall.com/v1/mcp/jira", "headers": { "Authorization": "Bearer kp_live_t_" } } } } ``` ## 3. What your token can and cannot do | | | | - | - | | **One server** | The token is audience-bound (RFC 8707) to the MCP server you approved. Pointed at another, it is refused — not degraded, refused. | | **One mode** | A token approved in Test mode reaches Test servers only, and resolves Test credentials. | | **One person — you** | The subject is fixed at mint and is covered by the token's signature. `X-KC-User` is not an identity and is ignored. | | **No AI agent** | It reaches no `/v1/ai` agent and no provider credential. It is a tool token, not a gateway key. | | **15 minutes** | Re-run the device flow when it expires. | ## Troubleshooting | What you see | What it means | | - | - | | `authorization_pending` | Nobody has approved the code yet. Keep polling on `interval`. | | `slow_down` | You polled faster than `interval`. Wait and try again. | | `expired_token` | The code is older than 15 minutes, or was never valid. Start again. | | `access_denied` | Someone declined it on the approval screen. | | `invalid_grant` | That `device_code` was already used. A token is issued once. | | `401 insufficient_scope` on a tool call | The token is not a `tool` token — it did not come from this flow. | | `401` naming a different resource | The token is bound to another MCP server. Get one for this server. | | `401 passthrough_identity_unbound` | The token names nobody. Tokens from the device flow always do; this one did not come from it. | ## If your server uses delegated OAuth A server in `auth.mode: "delegated"` also needs **your upstream account** connected once, from the MCP server's page in the console — see [the MCP gateway page](/ai-gateway/mcp-gateway). The MCP token above says who you are to KnoxCall; the connection is what lets KnoxCall act as you at the upstream. Nobody can connect on your behalf. # Data Residency & Customer-Managed Keys Source: https://docs.knoxcall.com/ai-gateway/data-residency Pin an AI agent's upstream egress to a region, and encrypt its data at rest under a key your own KMS gates. Both fail closed. # Data Residency & Customer-Managed Keys Two per-agent controls for teams with a regulatory constraint on where their prompts go and who can decrypt what the gateway keeps. Both **fail closed**. If the gateway cannot prove a destination is in your region, or cannot obtain your key, it refuses the request — it does not fall back to a global endpoint or to a KnoxCall-held key. Set both on the agent, not the gateway: a team usually has one gateway and several agents, and residency is a property of the workload, not the container. ## Data residency Set `data_residency_region` on an agent to one of `us`, `eu`, `uk`, `ca`, `au`, `jp`, `in`. ```bash theme={"dark"} curl -X PATCH https://api.knoxcall.com/v1/ai-gateway/agents/$AGENT_ID \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"data_residency_region": "eu"}' ``` From then on, every upstream call this agent makes on the `/v1/ai` data plane — the primary route, **every fallback route**, the output-schema retry, the semantic-cache embedder, and your PII analyzer sidecar if you have configured one — must target an endpoint KnoxCall classifies as serving that region. Anything else is refused with `403 residency_violation` before that call is made. **The `/v1/mcp` plane is not covered yet.** An MCP server's `upstream_url` is dialled without a residency check, so tool-call arguments and results from an agent's MCP tools can leave the region even when the agent is pinned. If you are pinning for a regulatory reason, do not attach MCP servers to that agent until this lands. Tracked as AIGW-77. The fallback path is the one that matters. An unpinned failover is exactly how a Frankfurt workload ends up in `us-east-1` at 3am: the primary 5xxs, the gateway does what it was configured to do, and nothing in the request looks wrong afterwards. A pinned agent skips an out-of-region fallback and surfaces the primary's error instead. ### What KnoxCall classifies | Endpoint shape | Region | | - | - | | `bedrock-runtime..amazonaws.com` | from the AWS region (`eu-west-2` → **UK**, not EU) | | `-aiplatform.googleapis.com` | from the Vertex region | | `.api.cognitive.microsoft.com` | from the Azure region | | `eu.api.openai.com` | EU | | `api.mistral.ai` | EU | | `api.cohere.com` | US | ### What it refuses, deliberately | Endpoint | Why | | - | - | | `api.anthropic.com` | Anthropic's public API is a single global endpoint. Use Bedrock or Vertex in your region. | | `api.openai.com` | OpenAI's default endpoint is global. Use `eu.api.openai.com` for EU. | | `generativelanguage.googleapis.com` | Google's Generative Language API is global. Use a regional Vertex endpoint. | | `.openai.azure.com` and `.cognitiveservices.azure.com` | These are two hostnames for the SAME Azure resource, and `` is a name its creator chose from a global namespace — nothing stops an East US resource being called `westeurope`. Neither can prove a region. Use the regional Cognitive Services endpoint. | | anything else | A host KnoxCall cannot classify is refused, not allowed. | **Unclassifiable means refused.** The three global endpoints above are exactly the hosts most likely to be left in a route by accident, so "unknown = allow" would make the pin decorative. If you need an endpoint we do not classify, open a ticket — we would rather add a documented rule than have you turn the pin off. ### What this control is, precisely It is an **administrative control on destination selection**. A pinned agent may only egress to endpoints KnoxCall has classified as serving the pinned region. It stops misconfiguration and silent drift, which is the failure mode that actually produces residency incidents. It is **not** a cryptographic or network guarantee: if you point your own route at a host you have classified in-region and it is not, you are exporting your own data and no gateway can stop that. Every vendor's "data residency" feature is this control. Ours says so. ### Auditing it Each `ai_gateway_usage` row records the region the agent was pinned to **at the time of that call**, so a residency audit reads evidence rather than the agent's current configuration. It is returned by the usage export alongside cost and attribution. ## Customer-managed encryption keys (CMEK) Set `cmek_key_id` on an agent to the id of one of your tenant master keys. That agent's PII token map — the reversible map behind [streaming PII redaction](/ai-gateway/pii-redaction), which holds the original values — is then encrypted under that key rather than the tenant's current active key. ### Prerequisite: BYOK `cmek_key_id` must name a key whose `wrap_method` is `customer_kms` — a key your own AWS/GCP/Azure KMS wraps. See [Bring your own key](/ai-gateway/byo-key). **A key KnoxCall wraps is refused**, with `503 cmek_unavailable`. This is the load-bearing check: without it, "CMEK enabled" could resolve to a platform-wrapped key and you would have a green checkbox and no custody. ### What happens when the key is unavailable Every one of these refuses the data-plane request with `503 cmek_unavailable`: * the key does not exist, or is not this tenant's; * the key is not customer-managed; * your tenant has no active master key; * your tenant's active key is no longer customer-managed (you rotated off BYOK); * your KMS refuses to unwrap it (a revoked grant, a disabled key, an outage). Nothing is written under a different key, and nothing is written in the clear. Revoking the grant in your KMS therefore stops the agent within the key-cache TTL — five minutes for BYOK keys — which is the same revocation contract the rest of BYOK carries. **Reads are not gated.** A KnoxCall envelope names its own key version, so rows written before you attached a CMEK still decrypt. Refusing to read them would destroy data you are entitled to see. Re-wrapping history under a new key is the BYOK rewrap sweep's job, not this setting's. ### Key rotation `cmek_key_id` names your customer-managed key **chain**, not one frozen version. Rotating your tenant master key the normal way keeps the agent working: writes follow whatever version of that chain is currently active. What is refused is a chain that stops being customer-managed — if you rotate back onto KnoxCall's platform wrap, the agent refuses rather than silently accepting a key we can unwrap. ### Scope today CMEK covers the agent's **PII token map** — the reversible `(surrogate → original)` store, which is the one place the gateway durably holds your original values. Two honest limitations: * **The response cache is not encrypted at all.** `ai_gateway_cache_entries` stores response bodies as plain bytes, and the buffered path caches AFTER detokenization — so for an agent with `pii_detokenize_response` on, the original values would sit in that table in the clear for the cache TTL. Rather than leave that beside a customer-managed key, **an agent with a `cmek_key_id` does not use the response cache at all.** You lose the cache; you do not lose custody. * **The MCP plane's stored configuration** (`ai_gateway_mcp_servers`) is not covered. Its upstream credentials are envelope-encrypted through the normal tenant key — customer-managed too when BYOK is configured for the tenant — but they do not follow the per-agent pin. # Providers & Day-Zero Models Source: https://docs.knoxcall.com/ai-gateway/day-zero-models Fourteen supported upstreams — Anthropic, OpenAI, Gemini, Cohere, Azure OpenAI, Ollama, Groq, Together, Mistral, DeepSeek, Fireworks, xAI, AWS Bedrock and any OpenAI-compatible endpoint — plus the runbook for adding a new model the day it ships: a manifest update, no remote config fetch. # Providers & Day-Zero Models ## Supported providers Create an agent against any of these upstreams. Each agent gets its own route with the provider key injected from a KnoxCall secret — your app never holds it. | Provider | Upstream | Auth | OpenAI base path | Streaming redaction | | - | - | - | - | - | | **Anthropic** | `api.anthropic.com` | `x-api-key` | — (native shape) | ✅ mid-stream (holdback FSM) | | **OpenAI** | `api.openai.com` | `Authorization: Bearer` | `/v1` | ✅ mid-stream | | **Gemini** | `generativelanguage.googleapis.com` | `x-goog-api-key` | — (native shape) | ✅ mid-stream | | **Cohere** | `api.cohere.com` | `Authorization: Bearer` | — (native shape) | buffered only | | **Azure OpenAI** | your `*.openai.azure.com` resource | `api-key` | `/openai/v1` | ✅ mid-stream | | **Ollama** (self-hosted) | your Ollama URL | optional Bearer | `/v1` | ✅ mid-stream | | **Groq** | `api.groq.com` | `Authorization: Bearer` | `/openai/v1` | ✅ mid-stream | | **Together AI** | `api.together.xyz` | `Authorization: Bearer` | `/v1` | ✅ mid-stream | | **Mistral AI** | `api.mistral.ai` | `Authorization: Bearer` | `/v1` | ✅ mid-stream | | **DeepSeek** | `api.deepseek.com` | `Authorization: Bearer` | `/v1` | ✅ mid-stream | | **Fireworks AI** | `api.fireworks.ai/inference` | `Authorization: Bearer` | `/v1` | ✅ mid-stream | | **xAI (Grok)** | `api.x.ai` | `Authorization: Bearer` | `/v1` | ✅ mid-stream | | **AWS Bedrock** | your `bedrock-runtime..amazonaws.com` | SigV4, signed by us | — (native shape) | buffered only | | **Other OpenAI-compatible** | your base URL | `Authorization: Bearer` | `/v1` | ✅ mid-stream | ### The OpenAI base path matters For every provider with a base path in that column, point an OpenAI SDK at **the agent URL plus that path** — `OPENAI_BASE_URL=https://.knoxcall.com/v1/ai//openai/v1` for Groq, `…/v1` for the rest. KnoxCall forwards `agent URL + everything after it` to the provider's host, so the sub-path is what turns `POST /chat/completions` into Groq's real `api.groq.com/openai/v1/chat/completions`. The Connect tab renders the right one for you; the catalog is the source of truth (`GET /admin/ai-gateway/quickstart/context` returns `openai_compat_path` per provider). ### Tenant-supplied upstreams Azure OpenAI, Ollama, Bedrock and "Other OpenAI-compatible" point at **your** endpoint, so you supply the upstream base URL when creating the agent. For Bedrock and "Other OpenAI-compatible" that URL is **resolved and checked before the agent is created**: a hostname that resolves to a loopback, private, link-local or cloud-metadata address is refused with a 400. That is not paranoia about your network — the request carries your *decrypted* provider credential to whatever that name resolves to, so it is the one place a typo or a hostile DNS record costs you a key. Ollama is deliberately exempt: pointing at your own box on `10.0.0.5` or `localhost:11434` is the entire point of it. "Other OpenAI-compatible" also requires a `default_model`, because we cannot know which model your endpoint serves — and for the same reason its cost is recorded as `unpriced`. Azure and Ollama are unpriced too: an Azure deployment is billed under your own Azure agreement, and a self-hosted Ollama box has no per-token price at all. ### AWS Bedrock: you keep the key, we sign Bedrock is the one provider with no static credential header. Its secret access key is never transmitted — it is the HMAC key for a signature over the request itself. So KnoxCall stores a **JSON AWS credential** as the secret: ```json theme={"dark"} { "accessKeyId": "AKIA…", "secretAccessKey": "…", "sessionToken": "optional" } ``` …and computes the SigV4 signature server-side over the finalized request, after PII tokenization and body injection, immediately before the bytes leave. Your application holds no AWS credential at all, and neither does anything downstream of us. Two limits worth knowing up front. Bedrock streams `application/vnd.amazon.eventstream` — length-prefixed binary frames, not SSE — so the mid-stream PII rewriter cannot apply; redaction runs on its **buffered** responses, exactly as it does for Cohere. And Bedrock prices are region-scoped, while [the shipped pricebook](/ai-gateway/budgets-finops) carries the `us-east-1`/`us-west-2` rates, so an agent pinned elsewhere is estimated rather than exact. Gemini and Cohere use their own native request/response shapes — send Gemini requests in Gemini's format and Cohere in Cohere's. The gateway proxies them through unchanged; [PII redaction](/ai-gateway/pii-redaction), [firewall](/ai-gateway/firewall), [budgets](/ai-gateway/budgets-finops), and usage attribution all still apply. OpenAI ↔ Anthropic shape translation is automatic on the buffered path for those two. ## Day-zero models — the runbook When a provider ships a new model, you don't wait on a KnoxCall release. Two steps, no remote config fetch (a deliberate post-supply-chain-compromise choice — the gateway never pulls config from a third party at runtime): 1. **Use it immediately.** Set the new model id in your request (or as the agent's default model). The gateway proxies any model the upstream accepts — there is no allowlist unless you add one. 2. **Price it (for budgets/chargeback).** Add a row to the relevant `packs/ai-gateway-pricebook/{provider}.json` manifest with the list price and a dated `effective_from`, then reseed. Until then the call still succeeds and is recorded with `cost_source = 'unpriced'` (never silently \$0), and the weekly pricebook-drift check raises an alertable `ai_gateway.unpriced_model` event so you know to add it. See [Budgets & FinOps](/ai-gateway/budgets-finops). That's the whole loop: a new model works on day zero; adding one manifest row makes its cost exact. ## Model allow/deny Optionally constrain which models an agent may call via its model allowlist / denylist (Models tab, or `model_allowlist` on the agent). A request for a model outside the allowlist is rejected before it reaches the upstream. # Errors Source: https://docs.knoxcall.com/ai-gateway/errors Every refusal the AI Gateway can return, the one envelope it arrives in, and which of them are safe to retry. # Errors The gateway refuses rather than degrades. Almost every entry on this page exists because serving the request would have meant applying less governance than you configured — so a refusal here is usually a control working, not a fault. This page is the whole surface in one place: the envelope you parse, the codes you can branch on, and what to do about each. ## One envelope Every refusal on the AI data plane — `/v1/ai/*` and everything in front of it: authentication, the MCP audience guard, the rate limiter, the boot sentinel — arrives in exactly this shape: ```json theme={"dark"} { "error": "budget_exceeded", "error_description": "Daily budget exceeded: $50.0031 ≥ $50", "code": "budget_exceeded" } ``` `error` and `code` always carry the **same** machine-readable identifier, so a client that reads either keeps working. `error_description` is always the human sentence — **never branch on it**; descriptions are rewritten whenever a clearer wording is found, identifiers are part of the contract. Some refusals add extra keys alongside the three (`utilization_pct`, `model`, `request_id`). Ignore keys you do not recognise; new ones are additive. **Shipped 2026-09-08 (AIGW-163).** Until then three incompatible shapes answered this one URL and they disagreed about which field was machine-readable: the pipeline sent `{error: "", error_description}`, the auth layer sent `{error: "Unauthorized", code, reason}` — the inversion — and the MCP audience guard sent a third reading in which `error` was a code that was not the `code`. If you wrote a client against the previous advice on this page, you read `code` and you are unaffected. **`reason` is gone**; its string is now `error_description`. `error` on an auth refusal is now the code rather than the word `Unauthorized`, and the `X-Auth-Failure` header still carries the same value. The endpoint and this envelope are in the OpenAPI spec as `POST /v1/ai/{agentSlug}/{upstreamPath}` — see [Execute AI Request](/api-reference/ai-gateway/execute). ### Errors mid-stream A streamed call commits its status line with the first frame, so a failure after that cannot be a status code. It arrives as a terminal SSE event carrying the same object: ``` event: error data: {"error":"upstream_stream_failed","error_description":"The response stream failed after it had started. Some of the response may be missing; the request was not completed.","code":"upstream_stream_failed","request_id":"0d5b2a9e-…"} ``` Treat an SSE stream that ends **without** this frame and without the provider's own terminal event as a transport truncation, and retry it. ### The management plane is a different envelope Everything under `/v1/ai-gateway/*` — creating gateways, agents, tokens, firewall policies, MCP servers, reading usage — is the **Management API**, not the data plane. It has its own credential and its own standard envelope, which is nested and is shared with every other `/v1` resource: ```json theme={"dark"} { "error": { "type": "not_found", "message": "Gateway not found.", "request_id": "0d5b2a9e-1f3c-4a7d-8e2b-6c9a1f4d7e35" } } ``` `error.type` is the machine field. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#errors). This one is deliberately unchanged: unifying it would be a breaking change to every resource in the product to fix an inconsistency that only ever existed inside `/v1/ai`. ### The MCP plane speaks JSON-RPC `/v1/mcp` answers JSON-RPC 2.0, so its refusals are that protocol's object, not the envelope above: ```json theme={"dark"} { "jsonrpc": "2.0", "id": 1, "error": { "code": -32601, "message": "Method not found" } } ``` | Code | Meaning | | - | - | | `-32600` | Invalid request. | | `-32601` | Method not found — including `resources/*` and `prompts/*`, which the gateway deliberately does not proxy ([why](/ai-gateway/mcp-gateway#resources-and-prompts-are-refused)). | | `-32001` | Unauthenticated, or the tool is not allowed. | | `-32002` | Blocked by the prompt firewall. | | `-32000` | A governance refusal — a spend cap, a token scope limit, a guardrail verdict. The HTTP status carries the class (`429` for a cap, and it carries `Retry-After`) and the message is the same sentence the `/v1/ai` refusal would have used. | | `-32603` | Internal error — including a paused or archived agent or gateway. | The one exception on that plane: a token presented to the wrong MCP resource is refused by the audience guard, which is an HTTP layer and answers the envelope at the top of this page. ## Retry, or don't | Status | Meaning here | Retry? | | - | - | - | | **400** | Your request was refused by a policy you configured. | No — nothing changes on a retry. | | **401** | The credential was rejected. | No — mint a new token. | | **403** | The credential is valid but not permitted to do this. | No. | | **404** | No agent with that slug for this tenant. | No. | | **422** | The request was understood and is unprocessable (schema violation, unsupported option). | No. | | **429** | A ceiling was reached — budget, token cap, or request rate. | Yes — **honour `Retry-After`**, which every 429 on this plane now carries. | | **500** | A fault on our side. | Yes, with backoff. | | **502** | The upstream provider failed. | Yes, with backoff. | | **503** | A control could not be applied, so the request was refused rather than served ungoverned. | Yes, but it will keep failing until the underlying configuration or dependency is fixed — read the code. | The 503s are the ones worth understanding: almost none of them mean "the gateway is down". They mean the gateway could not prove it was enforcing what you asked for, and refused instead of quietly doing less. ### `Retry-After` is exact, or absent A `429` from a spend cap carries the whole number of seconds until that cap's counter rolls over — daily budgets and signed token caps reset at **00:00 UTC**, monthly budgets at the first instant of the next UTC month — because that is when the refusal genuinely stops applying. A rate-limit `429` carries the remainder of its window. Where the server does not know the answer it sends **no header at all** rather than a guess: a wrong value is worse than none, since a client backs off on it either way. So `Retry-After` is never zero and never fractional, and its absence on a 429 means "back off with your own schedule", not "retry now". ## Authentication The code is also returned in the **`X-Auth-Failure`** response header, which lets a client or a proxy classify the failure without reading the body. | Code | Status | What happened | | - | - | - | | `missing_token` | 401 | No credential on the request. | | `malformed`, `bad_prefix`, `bad_env`, `bad_kind`, `bad_random`, `bad_crc_format`, `crc_mismatch`, `empty` | 401 | The token did not parse. `bad_prefix` is the common one after the 2026-09-01 rename — a `kc_`-prefixed AI token no longer parses; re-mint. | | `unknown_token` | 401 | Parsed, but matches no record. | | `capability_hmac_mismatch` | 401 | The token's scope does not match its signature. | | `tenant_key_missing` | 401 | The tenant master key was unavailable. | | `dpop_missing`, `dpop_jkt_missing` | 401 | The token is DPoP-bound and the proof was absent or unbindable. Sign `htu` against the **canonical public URL**, not a rewritten `Host`. | | `oneshot_already_used` | 401 | A one-shot token was replayed. It is consumed after authentication but *before* policy, so a call refused by a policy has still spent it. Mint one per attempt. | | `oneshot_consume_error` | 401 | The consume could not be recorded, so the call was refused rather than risk a replayable one-shot. | | `agent_missing` | 401 | The token names no agent — an agentless `tool` token presented to `/v1/ai`, which requires one. | | `outside_valid_window` | 403 | Presented outside the token's `valid_window`. | | `ip_cidr_mismatch` | 403 | Presented from outside the token's `ip_cidrs`. | | `tenant_unavailable` | 403 | The workspace is not currently servable. | | `step_up_required` | 401 | The token was flagged by [impossible-travel detection](/ai-gateway/tokens-and-dpop#impossible-travel-detection-and-step-up). | | `invalid_token`, `insufficient_scope`, `invalid_target` | 401 / 403 | The MCP audience guard: the token carries no authenticated scope, is not `tool`-kind, or is bound to a different MCP resource. Carries an RFC 6750 `WWW-Authenticate` challenge with the RFC 9728 `resource_metadata` pointer. | | `unknown_mcp_resource` | 404 | The path is not an MCP resource on this host. | | `missing_auth` | 401 | The request reached the rate limiter or the handler with no verified token — a mount error, refused rather than served unmetered. | Every one of these now writes an `ai_gateway.request_denied` audit row, on both planes — except a request with no token or an unknown token, which has no workspace to attribute to and must not be able to write to your audit log. ## Policy and pipeline ### Credentials and routing | Code | Status | What happened | | - | - | - | | `agent_not_found` | 404 | No agent with that slug for this tenant. | | `agent_token_mismatch` | 403 | The token belongs to a different agent. | | `agent_not_active` / `gateway_not_active` | 503 | The agent, or its gateway, is paused or archived. | | `agent_not_configured` / `agent_route_missing` | 503 | The agent has no usable upstream route. | | `route_env_mismatch` | 403 | A Test token addressed a Live route, or the reverse. | | `route_field_actions_unsupported` | 503 | The route carries Relay field actions, which `/v1/ai` cannot apply. Refused rather than silently skipped. | | `route_signature_unsupported` | 503 | The route requires a rolling URL signature, which `/v1/ai` cannot compute. | | `agent_route_misconfigured` / `credential_unavailable` | 503 | The provider credential could not be resolved. | | `linked_secret_domain_violation` | 403 | A shared secret's domain lock refused this upstream. Audited on **both** tenants. | | `residency_violation` | 403 | The request would have left its [pinned region](/ai-gateway/data-residency). | | `upstream_error` | 502 | The provider failed after every configured failover hop. | | `upstream_stream_failed` | — | A STREAM failed after its first frame. There is no status left to change, so it arrives as the terminal SSE `event: error` above. | | `ai_gateway_unavailable` | 503 | The plane failed to start. A platform fault, not your request. Carries `Retry-After`. | ### Model, tools and output | Code | Status | What happened | | - | - | - | | `model_denylisted` / `model_not_allowed` | 403 | The requested model is denied, or not on the agent's allowlist. | | `tool_not_allowed` | 403 | A tool outside the agent's `tool_allowlist`. | | `batch_input_not_inspectable` | 422 | A batch envelope the gateway cannot open — and it will not forward what it cannot screen. | | `output_schema_violation` | 422 | The response failed the agent's `output_schema` and the action is `block`. Under `warn` the response is served with `X-Knox-AI-Output-Warning`; a retried call carries `X-Knox-AI-Retry: output_schema`. | ### Firewall See [Prompt firewall](/ai-gateway/firewall). | Code | Status | What happened | | - | - | - | | `firewall_block` | 400 | A rule matched under a policy whose action is `block`. | | `firewall_policy_unavailable` | 503 | The agent's policy could not be loaded. | | `firewall_policy_missing` | 503 | The agent references a policy that no longer exists. Re-attach one, or clear `firewall_policy_id`. | | `firewall_policy_degraded` | 503 | One or more rules failed to compile, so a `block` policy is only partly applied. On `warn`/`tag` the request proceeds and the degradation is written to your firewall event ledger. | ### Budgets and token caps See [Budgets & FinOps](/ai-gateway/budgets-finops). | Code | Status | What happened | | - | - | - | | `budget_exceeded` | 429 | The agent's (or its gateway's — the message is prefixed `Gateway budget:`) daily or monthly cap is spent. Carries `Retry-After`: the seconds to that cap's UTC rollover. | | `budget_per_call_exceeded` | 400 | This one request asks for more than `budget_per_call_max_tokens`. | | `budget_unenforceable` | 503 | The spend counter is unreadable, and the agent's action is `block` — so the request was refused rather than served unmetered. Responses in this state carry `X-Knox-AI-Budget-Unmetered: 1`. | | `budget_unpriced_model` | 403 | The agent has a HARD spend cap and the model it would run on has no price in KnoxCall's pricebook — so the call would not move the spend counter and the cap could never bind. Refused PRE-flight, before the provider is paid. Add the model to the pricebook, choose a priced one, or set `budget_overage_action` to `warn`. Structural for `openai-compatible`, `azure-openai` and `ollama`, whose endpoint and model are yours. | | `token_scope_limit_exceeded` | 429 | The **token's own** signed daily cost or token cap is spent. Carries `Retry-After` to the next 00:00 UTC. | | `token_scope_malformed` | 403 | The token's scope carries an unusable limit. Re-mint. | | `token_scope_unattributable` | 403 | The call cannot be attributed, so a token cap cannot be counted against it. | | `token_scope_unenforceable` | 503 | The counter or pricebook needed to enforce a signed cap is unavailable. | | `token_scope_check_failed` | 503 | The signed cap could not be read at all. Refused rather than served unmetered. | | `token_scope_unpriced_model` | 403 | The model is not in the pricebook, so a signed cost cap could not be applied. | | `provider_not_in_token_scope`, `model_not_in_token_scope`, `tool_not_in_token_scope`, `route_not_in_token_scope` | 403 | The token's capability scope does not cover this call. | | `malformed_token_scope` | 403 | The scope is not a usable restriction — refused rather than read as "unrestricted". | | `rate_limit_exceeded` | 429 | A request-rate ceiling — including too many **failed** authentications from one address, which is refused before the token is checked. Carries `Retry-After`. See [Limits](/ai-gateway/limits). | ### PII and guardrails See [PII redaction](/ai-gateway/pii-redaction) and [Guardrail webhook](/ai-gateway/guardrail-webhook). | Code | Status | What happened | | - | - | - | | `pii_policy_unavailable` / `pii_policy_degraded` | 503 | The agent's PII policy could not be loaded, a recognizer failed to compile, or its Presidio analyzer could not run — including a timeout or error on this one request. Refused rather than served with redaction partly off; a per-request analyzer failure is retryable. | | `pii_tokenize_failed` | 503 | Tokenization failed, so the prompt was not forwarded. The raw value never leaves. | | `pii_detokenize_unavailable` | 503 | The token map needed to restore values was unavailable. | | `guardrail_block` | 400 | Your guardrail webhook returned a blocking verdict. | | `guardrail_streaming_unsupported` | 400 | A guardrail configuration that cannot be applied to a streamed response. | | `streaming_redaction_unsupported` | 400 | A streamed response could not be redacted mid-stream, so it was refused rather than streamed in the clear: either the provider has no mid-stream redactor (Cohere, Bedrock), or the endpoint streams an event family KnoxCall does not read (the OpenAI Responses API, legacy `/v1/completions`). Retry buffered — drop `stream: true`. | | `streaming_disabled` | 400 | The agent has streaming turned off, and the request asks the provider to stream (`stream: true`, or Gemini's `:streamGenerateContent`). Send it without `stream: true` (Gemini: `:generateContent`), or enable streaming on the agent. | | `upstream_stream_shape_unexpected` | 502 | A streamed request was answered with a body that is not an event stream, which KnoxCall cannot redact frame by frame. Refused rather than forwarded unprotected. Retry without `stream: true`. | | `pii_unscannable_body` | 400 | The request body is a shape the PII walker cannot open (a compressed or file-referenced batch), so it could not be screened. The gateway does not forward what it cannot screen. | | `pii_tokenize_failed` | 503 | Tokenization threw. The prompt was not forwarded — the raw value never leaves. | | `canary_leak` | 400 | The provider's response echoed this agent's system-prompt canary, which is proof the system prompt was extracted by the prompt in this request — not a heuristic guess. Refused when the attached firewall policy's action is `block`; under `warn` the response is served. **On a streamed response the refusal cannot be a `400`** — the `200` went out with the first frame — so the stream stops at the leak and ends with an `event: error` frame carrying this code; watch for that frame, not the status. The marker itself is spliced out of what you receive, buffered and streamed, **except in the `monitor` streaming mode**, which passes the response through unredacted by design. | | `tool_arguments_too_large` | — (streamed only) | One streamed tool call's arguments exceeded 4,194,304 characters. The gateway holds each call's arguments until the call finishes so it can scan them for PII, and past that ceiling it stops the stream rather than release them unscanned. Arrives as an `event: error` frame; the status line already said `200`. See [streamed tool calls](/ai-gateway/pii-redaction#streamed-tool-calls). | ## What is recorded Every refusal above writes one audit row under the single action **`ai_gateway.request_denied`**, with the specific cause in the row's details. One action name for every denial is deliberate: a new refusal reason cannot invent an action nobody is alerting on. Alert on `ai_gateway.request_denied` and read the reason from the row. Every response — refusal or not — carries **`X-Request-Id`**. Quote it when you contact support; it is the key into the audit trail for that exact call. # Prompt Firewall Source: https://docs.knoxcall.com/ai-gateway/firewall Built-in prompt-injection heuristics, tenant-supplied regex/keyword rules, and canary tokens that detect system-prompt extraction — enforced on every request. # Prompt Firewall The prompt firewall screens every request before it reaches the provider. Built-in heuristics always run — they cannot be disabled — and you can layer your own rules on top. ## Built-in heuristics The firewall ships with heuristics for common prompt-injection and jailbreak patterns (for example, instruction-override attempts and known jailbreak templates). When a request matches **and a policy with `action: "block"` is attached to the agent**, it is refused with `HTTP 400`: ```json theme={"dark"} { "error": "firewall_block", "error_description": "Request blocked by firewall policy: ignore_previous_instructions, dan_jailbreak" } ``` **Detection is on by default; blocking is not.** The built-in heuristics run on every request whether or not a policy is attached, but with **no policy attached the outcome is capped at `warn`** — the match is recorded, the request still reaches the provider, and you get no `400`. The gateway never blocks on an action you did not configure. Nor does an agent created through `/v1` or the dashboard start out unprotected *and* unblocking by accident: it is given the tenant's **Default** policy, whose action is also `warn`. That is deliberate — a policy every future agent inherits must not be able to start refusing their traffic on its own. So blocking is opt-in on every path. Either change the Default policy's action to `block`, or [create your own](#your-own-rules) with `action: "block"` and set the agent's `firewall_policy_id`. Until you do, treat the firewall as an alerting control, not a blocking one. ## Your own rules A firewall **policy** attached to an agent can add: * **Regex rules** — validated with a safe-regex guard so a pathological pattern can't cause catastrophic backtracking. * **Keyword rules** — literal terms to block or flag. A policy carries one `action`, which decides what a match does: | `action` | Outcome on a match | | - | - | | `block` | The request is refused with `400 firewall_block` and never reaches the provider. | | `warn` | The request proceeds; the match is recorded with `firewall_outcome = "warn"`. | | `tag` | The request proceeds; the match is recorded with `firewall_outcome = "tag"`. | With **no policy attached**, the outcome is `warn`. Test a policy against sample input from the **Firewall tab** on the gateway detail page, or programmatically with [`POST /v1/ai-gateway/firewall-policies/test`](/api-reference/ai-gateway/firewall-policies-test), before you attach it. The tester compiles rules through the same validator the writer uses, so it refuses exactly what a create would refuse — a pattern that passes the tester can actually run. ### When a blocking policy cannot be fully applied A policy set to `block` is a promise, so the gateway refuses rather than serve a request under a policy it knows is incomplete: | Code | Status | When | | - | - | - | | `firewall_policy_unavailable` | 503 | The agent's policy could not be loaded (a database fault). | | `firewall_policy_missing` | 503 | The agent references a policy id that no longer exists for this tenant. Re-attach a policy or clear `firewall_policy_id`. | | `firewall_policy_degraded` | 503 | One or more rules failed to compile, so the policy is only partly applied. On a `warn` or `tag` policy the request proceeds instead, and the degradation is written to the tenant-visible firewall event ledger — it is never only a server-side log. | ## Canary tokens Enable `canary_enabled` on a policy and the gateway injects a unique `kc_canary_<16hex>` token into every system prompt before forwarding upstream. If the model ever echoes that token back in its response — a strong signal that a prompt-injection attack extracted the system prompt — the gateway: 1. Emits an `ai_gateway.canary_leak` audit entry at **critical** severity. 2. Records the **policy's own action** as `firewall_outcome` on the usage record — `block` when the policy's action is `block`, otherwise `warn`. 3. Under a `block` policy, **refuses** the response: `400 canary_leak` when buffered, or — when streamed — stops the stream at the leak and ends it with an `event: error` frame carrying `canary_leak`. Anything already sent before the leak contained no canary and stays delivered. 4. Fires any [compliance-pack](/ai-gateway/compliance-packs) alert rules listening for `ai_gateway.canary_leak`. The canary is stripped from the response before it reaches your app, so it's invisible in normal operation — **except in the `monitor` streaming mode**, which passes the response through unredacted and only inspects it at the end. An agent in `monitor` mode delivers a leaked canary before any refusal can fire, so do not pair it with a `block` policy you are relying on. ### Where the canary is placed The canary goes into the system prompt of the request you send, in the form that request's API uses. Your own system prompt is always forwarded unchanged; the canary is added alongside it. | Request body | Where the canary goes | | - | - | | Anthropic Messages, and Bedrock InvokeModel with an Anthropic body | Appended to `system`: a new line on a string, or one more `text` block on a block array | | OpenAI Chat Completions, OpenAI-compatible providers, Cohere v2 | A leading `system` message | | OpenAI Responses | A leading `developer` item in `input` — not `instructions`, which the API echoes back in its response | | Gemini | One more part on `systemInstruction` (or `system_instruction`) | | Bedrock Converse | One more `{ "text": … }` block on `system` | | Cohere v1 | Appended to `preamble` | | Legacy completions, embeddings, batch envelopes | Nothing — these have no system prompt, so no canary is sent and none is watched for on that request | ## Outcomes in telemetry The firewall outcome is recorded on every call and exported on the [OpenTelemetry span](/ai-gateway/observability) as `knoxcall.ai_gateway.firewall_outcome` (`pass` / `warn` / `block` / `tag`), so you can alert and dashboard on it in your own backend. # Bring Your Own Scanner Source: https://docs.knoxcall.com/ai-gateway/guardrail-webhook Point an agent at your own DLP or prompt-security endpoint and every prompt — and optionally every response — is offered to it for a verdict before it moves. Signed, timed out, and fail-open or fail-closed by your choice. # Bring Your Own Scanner KnoxCall ships its own [prompt firewall](/ai-gateway/firewall) and its own [PII detectors](/ai-gateway/pii-redaction). They are also *ours*. If you have already bought and tuned a DLP or prompt-security product, adopting an AI gateway should not mean replacing it. So don't. Point the agent at an HTTPS endpoint you control, and every prompt — and, if you want, every response — is POSTed to it for a verdict before it moves. ```bash theme={"dark"} curl -X PATCH https://api.knoxcall.com/v1/ai-gateway/agents/$AGENT_ID \ -H "Authorization: Bearer $KNOXCALL_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "guardrail_webhook_url": "https://scanner.internal.example.com/inspect", "guardrail_webhook_secret_id": "'"$SIGNING_SECRET_ID"'", "guardrail_webhook_mode": "both", "guardrail_webhook_timeout_ms": 1500, "guardrail_webhook_failure_action": "fail_closed" }' ``` ## What your endpoint receives ```json theme={"dark"} { "direction": "request", "tenant_id": "…", "agent_id": "…", "agent_slug": "support-bot", "request_id": "…", "model": "claude-sonnet-5", "provider": "anthropic", "body": "{\"messages\":[{\"role\":\"user\",\"content\":\"my SSN is KC_SSN_a1b2\"}]}", "truncated": false } ``` `body` is **exactly what the upstream provider would receive, minus the credential** — the same buffer, taken at the same point in the pipeline. That is the guarantee, and it is the one worth having: your scanner is a third party from KnoxCall's point of view, and it is never shown more of your callers' data than the model provider is. Read that as written: **the same bytes as the provider**, not "guaranteed redacted". If the agent has PII tokenization enabled, the hook sees the tokenized form — the example above shows `KC_SSN_a1b2`. If it does not, or if the request uses a body shape our outbound tokenizer does not yet walk (a non-JSON body; Cohere's `message`/`chat_history`; tool descriptions), the hook sees the raw value — because so does the provider. Tokenization coverage is tracked as AIGW-212; until it closes, treat your scanner as being inside the same trust boundary as your model provider, and pick it accordingly. Three things your endpoint will **never** see: * a resolved provider credential — on the request direction because evaluation runs before the route's `{{secret_id:…}}` templates are rendered and before Bedrock's SigV4 signing, and on the response direction because every credential resolved for the request is redacted out of the body by VALUE before the payload is built. Redacting on the value is what makes the second case hold: the response is the provider's, the provider is whatever host your route points at, and it can echo the key back under any field name it likes — a name a denylist cannot predict. Where a value is removed you will see `[REDACTED_SECRET]` in `body`; * the caller's phantom token, or their KnoxCall API key; * KnoxCall's own canary marker, which is injected after the hook runs (a scanner that had never heard of it would reasonably flag it as an anomaly). On the **response** direction, `body` is the response as your caller would receive it — after PII redaction and detokenization, and after the credential scrub above. If the provider compressed it (`Content-Encoding: gzip`, `br`, `deflate`), it is **decompressed for you**: a scanner handed raw gzip bytes cannot scan them. A response we cannot decompress is sent as an empty `body` rather than as bytes neither of us can read — your hook still runs and can still block. A body over 256 KB is **truncated, not skipped**, and `truncated: true` says so. A scanner that knows it saw a prefix can still refuse; one that is silently never called cannot. ## What your endpoint returns `200` with: ```json theme={"dark"} { "action": "block", "reason": "PCI data detected in prompt" } ``` | `action` | What happens | | - | - | | `allow` | The request proceeds. | | `block` | 400 `guardrail_block`, with your `reason` in the message. On the request direction the provider is never called at all. | | `flag` | The request proceeds and the outcome is recorded as non-passing on the usage row and in the firewall event ledger. | Anything else — a different verb, a non-object body, an empty body, a non-2xx status — is treated as a **non-answer**, not as `allow`. See below. ## Verifying the signature Set `guardrail_webhook_secret_id` to a KnoxCall secret holding an HMAC key, and every delivery carries: ``` x-knox-guardrail-timestamp: 1787659200 x-knox-guardrail-signature: x-knox-guardrail-delivery: ``` The signature is `HMAC-SHA256(secret, ".")`. The timestamp is **inside** the signed material, not merely alongside it — a signature over the body alone is replayable forever. ```python theme={"dark"} import hmac, hashlib, time def verify(secret: str, timestamp: str, body: str, signature: str) -> bool: if abs(time.time() - int(timestamp)) > 300: # reject stale deliveries return False expected = hmac.new( secret.encode(), f"{timestamp}.{body}".encode(), hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, signature) # never == ``` Use a constant-time comparison. A `==` leaks the expected signature a byte at a time, and it is our HMAC it leaks. ## When your scanner is down This is the decision that matters, and it is yours rather than ours — a marketing-copy agent should keep serving; a claims-processing agent should not. | `guardrail_webhook_failure_action` | Behaviour | | - | - | | `fail_open` (default) | The request proceeds. This is the posture an agent already has with no hook configured. | | `fail_closed` | The request is refused with 400 `guardrail_block` and a message naming the failure. | It applies **identically to every way the hook can fail to answer**: a timeout, a refused connection, a refused destination, a non-2xx, an unparseable body, or an `action` verb we do not recognise. A control with six failure modes and two behaviours is one nobody can reason about, and the gap is always the mode nobody enumerated. ## Streaming An agent in `response` or `both` mode **refuses a streaming request** with 400 `guardrail_streaming_unsupported`. That is deliberate. On a stream the bytes are on the wire before a complete response exists, so a response-direction hook could observe but never block — and a control that works on the buffered path and quietly does not on its streaming twin is worse than no control, because you would believe you had one. If you need both, run the hook in `request` mode (which works on every path, since it runs before dispatch) or serve that agent buffered. ## The limits, and why | | | | - | - | | Scheme | `https` only, not negotiable | | Destination | Resolved and refused at write time **and** on every call — private, loopback, link-local and cloud-metadata addresses are rejected | | Timeout | 100 ms – 10 s | | Payload | 256 KB, then truncated with a flag | | Response read | 64 KB | The destination is re-checked on every call rather than trusted from the write: DNS is not a promise, and a hostname that resolved to a public address yesterday can resolve to `169.254.169.254` today. The timeout and read caps exist because a request waiting on your scanner is holding one of the gateway's API workers, and there are only four of them. ## What gets recorded A non-`allow` verdict lands in two places you can query: * `ai_gateway_usage.firewall_outcome` — `block` or `tag`, alongside the cost and token counts for that call; * an `ai_gateway_firewall_events` row whose `match_summary` reads `guardrail_webhook::` — so `guardrail_webhook:request:timeout` is distinguishable from `guardrail_webhook:request:block` when you are working out whether your scanner is refusing things or simply unreachable. # Limits & Availability Source: https://docs.knoxcall.com/ai-gateway/limits The concrete ceilings the AI Gateway data plane enforces — request rates, body sizes, token TTLs, scan and hold-back bounds — and an honest statement of what we do and do not promise about availability. # Limits & Availability Every number on this page is the value the gateway actually enforces. Where a limit is not enforced, this page says so rather than leaving you to infer one. ## Request rate Sliding windows over a shared cross-worker store, so the ceiling is the ceiling across the whole fleet — not per process. | Plane | Bucket | Ceiling | | - | - | - | | `/v1/ai/` | per phantom token | **300 / minute** | | `/v1/ai/` | per agent (all of that agent's tokens together) | **1,200 / minute** | | `/v1/mcp/` | per phantom token | **600 / minute** | | `/v1/oauth/token` | per (client IP, issuer, subject) | **30 / minute** | | `/v1/oauth/token` | per client IP | **60 / minute** | | `/v1/ai` and `/v1/mcp` together | **failed** authentications per client address (an IPv6 /64 counts as one) | **60 / 5 minutes** | The failed-authentication bucket counts only requests whose phantom token was refused — missing, unknown, expired, revoked, or presented from outside its allowed window or network. Successful requests never count, however many share an address. Once an address has used its allowance, every request from it to either plane is refused with `429` **before** its token is checked, until the window drains; `Retry-After` says when. These are abuse floors, not product quotas: they are the same on every plan, and a normal integration should never meet one. Exceeding a bucket returns `429`. The rate limiter **fails closed**. If its store is configured but unreachable, the plane refuses rather than admitting unmetered traffic — for a gateway whose job is credential custody, an unmetered data plane is the worse failure. (In a local development install with the store deliberately disabled it falls back to an in-process window, so limits still apply while you develop.) ## Concurrent MCP sessions | Plane | Bucket | Ceiling | | - | - | - | | `/v1/mcp/` | per token, per server, per environment | **16 open upstream sessions** | An MCP client picks its own `mcp-session-id`, so without a cap it would pick how many credentialed upstream connections KnoxCall holds open on its behalf. Past the ceiling the least-recently-used session is **closed**, not refused: a resource bound should not become a `503` for a client that opened one connection too many. Sessions are also swept after 5 minutes idle and retired after 15 minutes regardless, so a credential the upstream has revoked stops working on a schedule we control. 16 is the number across the whole fleet. It is declared that way in the code (`MAX_SESSIONS_PER_PRINCIPAL_FLEET`) and divided by the API worker count, because the session pool is necessarily per-process — it holds real sockets — and a per-worker figure would quietly multiply into a limit nobody had written down. ## Request size | Plane | Maximum request body | | - | - | | `/v1/ai/` | **1 MB** | | `/v1/mcp/` | **4 MB** | | `/v1/oauth/token` | **32 KB** | An oversized body is rejected by the body parser before any gateway policy runs. There is **no ceiling on response size** — the gateway streams or buffers whatever the upstream returns. ## Tokens | Limit | Value | | - | - | | Phantom-token TTL, minimum | **60 seconds** | | Phantom-token TTL, maximum | **90 days** | | Phantom-token TTL, default | **30 days** — a mint without `expires_in_seconds` gets this bounded TTL, not a permanent token | | Federated token TTL (`/v1/oauth/token`) | the binding's `access_token_ttl_seconds`, default **900 seconds** | | DPoP proof `iat` tolerance | **±60 seconds** of server time | A requested TTL outside `[60s, 90d]` is clamped into range rather than rejected. The default is a bounded 30-day TTL, not "no expiry" — a mint without `expires_in_seconds` used to produce a token that never expired at all, and any key minted before that changed is still out there with a NULL `expires_at`. Set `expires_in_seconds` explicitly on every mint that is not a hand-held credential, and re-mint anything issued earlier that you cannot account for. ## Guardrail bounds | Limit | Value | What it means | | - | - | - | | Prompt-firewall scan window | **64,000 characters** | The firewall scans the first 64 KB of extracted prompt text. Text past it is not scanned. | | Streaming hold-back window | **96 characters** minimum, 96 default | The sliding tail the streaming redactor holds back. Values below the floor are raised to it, because a smaller window silently loses detection of anything longer than it. | | Streaming hold-back ceiling | **8 KB**, or 8× the configured window | A pattern that never completes is redacted rather than held for ever. | | Content blocks tracked per stream | **64** | One redaction window per block (`n>1` completions, multi-block messages). Past 64 the extras share a window: still redacted, only their interleaving degrades. | | MCP sessions per token, per server, per environment | **4** | The least recently used is closed when a fifth is opened. | | MCP session idle timeout | **5 minutes** | Swept every 60 seconds. | ## Caching | Limit | Value | | - | - | | Cache TTL | `cache_ttl_seconds` on the agent, default **0** (no expiry-based eviction) | | Semantic similarity threshold | `cache_similarity_threshold`, default **0.95** | | What is cacheable | buffered `2xx` responses only — streaming responses are never cached | ## What is *not* limited Stated explicitly, because an unstated limit reads as an enforced one: * **No per-tenant or per-plan request quota.** The ceilings above are platform-wide abuse floors. Plan-based quotas are a separate piece of work. * **No token-count quota.** Spend is bounded in dollars by [budgets](/ai-gateway/budgets-finops), not by a token allowance. * **No response-size ceiling.** * **No cap on agents, gateways, or tokens per tenant.** ## Availability KnoxCall does **not** publish an uptime percentage for the AI Gateway, and will not until one can be measured against a defined error budget and backed by a credit schedule. Quoting a number we cannot compute would be worth less than saying so. What we do commit to today: * **Live status and incident history** at [status.knoxcall.com](https://status.knoxcall.com), which is where an incident is posted first. * **Degrade toward refusal, not toward silence.** When a control cannot run, the gateway refuses the request rather than serving it with the control off — an unloadable firewall policy, an unresolvable PII policy, an unreachable rate-limit store and an unproven token environment all fail closed. Detection-only signals (impossible-travel geolocation) fail open, because losing one comparison costs a comparison while blocking on it costs access. * **No silent zeros.** An unpriced model is recorded as `unpriced`, never as \$0; a missing usage figure is reported as missing. * **Failover you configure.** Per-agent [failover chains](/ai-gateway/budgets-finops#failover-chains-a-different-kind-of-fallback) move a 5xx to the next upstream you nominate, each hop with its own credential. If you need a contractual availability commitment, raise it with us before you build against one — we would rather negotiate a number we can hold than publish one we cannot. # MCP Gateway Source: https://docs.knoxcall.com/ai-gateway/mcp-gateway Govern MCP tool traffic like any other AI egress: DPoP-bound capability tokens as the agent credential, a tool allowlist, PII inspection of tool arguments and results, and prompt-injection screening — in front of your MCP servers. # MCP Gateway The Model Context Protocol (MCP) lets agents call tools on MCP servers. KnoxCall puts those calls behind the same governance as the rest of your AI egress — so an agent presents a **short-lived, sender-constrained KnoxCall token** instead of a long-lived server credential, and every `tools/call` is screened before it reaches the upstream. Point an MCP client at `https://.knoxcall.com/v1/mcp/`. ## What it governs For a request through an MCP server, the gateway: * **`initialize` / `ping`** — pass through to the upstream. They carry protocol negotiation, not tenant data. * **`tools/list`** — returns only the tools your **allowlist** permits (intersected with the token's tool scope). Tools you haven't allowed are never advertised. * **`tools/call`** — 1. **Authorizes** the tool against the allowlist — a call to a tool that isn't allowed is rejected with a JSON-RPC error and **never forwarded**. 2. **Screens the arguments** through the [prompt firewall](/ai-gateway/firewall) — an injection attempt in a text argument is blocked. 3. **Redacts PII in the arguments** before forwarding, so sensitive data never reaches the upstream MCP server. 4. Forwards the (redacted) call to the upstream. 5. **Redacts PII in the result** on the way back to the client. Every governed call is recorded in `ai_gateway_mcp_calls` with its outcome (`ok` / `blocked_tool` / `blocked_firewall` / `error`), its tool name, its PII-redaction count and its latency. A **refusal** additionally writes an audit row under the single action `ai_gateway.request_denied`, with the specific cause in the row's details — `mcp_tool_blocked`, `mcp_firewall_block`, `mcp_upstream_refused`, `firewall_policy_unavailable`, `firewall_policy_missing`. One action name for every denial is deliberate: a new refusal reason cannot silently invent an action nobody is alerting on. Alert on `ai_gateway.request_denied` and read the reason from the row. **Your system-prompt canary is watched for in both directions.** The canary the [prompt firewall](/ai-gateway/firewall) injects into your agents' system prompts has no reason to appear in tool traffic — nothing on this plane injects one — so if it does, an agent has extracted a system prompt and is moving it through a tool. The most direct route is the call's **arguments**, out to an upstream an attacker controls. Wherever it appears, the gateway strips it (`[REDACTED:CANARY]`) before it leaves and raises an `ai_gateway.canary_leak` audit entry at **critical** severity against the MCP server — one per call, however many times the marker appeared, and carrying a count rather than the marker itself. That alert is **not** an `ai_gateway.request_denied` row, because the call is not refused: the marker never reaches the upstream, and no policy on this plane asks for more. Alert on `ai_gateway.canary_leak` separately. If none of your agents has ever run with a canary, there is nothing to watch for and nothing is scanned. ### `resources/*` and `prompts/*` are refused The gateway governs **tools**. `resources/read`, `resources/list`, `prompts/get` and `prompts/list` are answered with JSON-RPC `-32601` (method not found) and are never proxied to your upstream. This is a decision, not an oversight: `resources/read` returns file and data content and `prompts/get` returns prompt text — exactly the payloads the tool-call controls exist to screen — so proxying them would be a way to pull that content through the gateway with none of those controls applied. An MCP server whose value is in its resources is not usable through KnoxCall today; say so to us if that is your case. ## The credential MCP tool access uses a **tool-bound phantom token** (kind `tool`), [DPoP](/ai-gateway/tokens-and-dpop)-bound by default — sender-constrained, short-lived, and revocable. This is the credential the MCP spec recommends and that almost nobody productizes: your agent never holds the upstream server's key, and a leaked token is useless without its DPoP key. ## How the server authenticates upstream Three modes, set per server on `auth.mode`. ### `secret` (default) The server dials its upstream with one credential resolved from your secret store: `{ "headers": { "Authorization": "Bearer {{secret_id:}}" } }`. The credential is envelope-encrypted at rest, decrypted per connection, and never stored in the server row — a literal value there is refused at write time and again at connect time. Every user of the server reaches the upstream as **the same principal**. That is fine for a read-only internal service, and it is the wrong shape for anything where the upstream has its own idea of who may do what: a shared Jira credential sees every project any of your users can, regardless of which of them asked. ### `passthrough` — each user acts as themselves ```json theme={"dark"} { "mode": "passthrough" } ``` The caller supplies their own upstream credential per request: ```http theme={"dark"} POST /v1/mcp/my-jira Authorization: Bearer kp_live_t_... # your KnoxCall tool token, minted FOR YOU X-KC-Upstream-Authorization: Bearer ``` KnoxCall forwards that value to the upstream as `Authorization` and **stores nothing**. The upstream applies its own authorization to the real person, so least privilege is whatever your upstream already enforces — not whatever a shared service account happens to be able to do. What the gateway still does on every call: the tool allowlist, the token's own `tools` scope, the prompt-injection firewall on tool arguments, and PII redaction of arguments and results. What it stops doing is holding a credential. **Rules this mode enforces, and why:** * **The token has to name a person — and only the token does.** The credential belongs to a human and the upstream will act as that human; the only record connecting the two is the one we write. So the identity comes from the **subject bound into your `tool` token**, which is covered by the token's capability signature and fixed at mint. A token that names nobody is refused on a passthrough server, whatever headers it sends. `X-KC-User` is **not** an identity here and is ignored for binding. It used to be one, checked only against your directory — which meant anyone holding a credential could record the action against any provisioned employee. If you send it and it disagrees with the token, the call is still attributed to the token's subject and the disagreement is logged. Two ways to get a subject-bound token, both of which prove the person before minting: | Path | Who it is for | How the person is proven | | - | - | - | | OIDC exchange with a binding that sets `subject_claim` | a workload acting for a named user | the claim is read out of an id\_token your IdP **signed** | | Device-code grant | a human on Claude Code / Claude Desktop / Cursor | they sign in to KnoxCall and approve the device | Set `subject_claim` on the binding to the claim that names the person (for example `email`). Its value is resolved against your directory (`ai_gateway_user_attribution`) at exchange time; a claim naming someone you have not provisioned is refused rather than minted unbound. Leave it null for ordinary CI — a build job is not a person, and a token with no subject keeps working everywhere except a passthrough server. * **No stored headers.** A passthrough server cannot also carry `auth.headers` — sending both would put the shared credential and the user's on the same request and let the upstream pick. * **Missing credential = refused, never anonymous.** There is nothing to fall back to, and dialling unauthenticated would look like the feature working. * **A KnoxCall token is refused.** If the header value looks like a KnoxCall credential — `tk_` (tenant API key), `kc_` (OAuth 2.1 access token), `kr_` (OAuth 2.1 refresh token), `kp_` (AI data-plane phantom token) or `AKE` (enterprise access key) — the call is rejected: that header goes to a third-party server, which has no use for it. The list is `KNOXCALL_CREDENTIAL_PREFIXES` in `src/proxy/passthrough.ts`, and every authenticator's prefix has to be on it. * **Connections are never shared between users.** Upstream connections are pooled for reuse; the pool key includes the token's bound subject and a non-reversible tag derived from the credential, so two people can never end up on one connection — including when they hold the same token and send the same `Mcp-Session-Id`. The credential is not written to logs, audit rows, traces, or usage records, and it is never echoed in an error message. ### `delegated` — each user acts as themselves, without carrying a credential ```json theme={"dark"} { "mode": "delegated", "delegated": { "authorize_url": "https://provider.example.com/oauth/authorize", "token_url": "https://provider.example.com/oauth/token", "client_id": "your-oauth-app-client-id", "client_secret_ref": "{{secret_id:}}", "scopes": ["read:issues", "write:issues"] } } ``` Passthrough asks every caller to carry their own upstream credential. Delegation is for when they should not have to: the person **consents once** through the upstream's own OAuth, KnoxCall envelope-encrypts their refresh token under your tenant key, and mints a short-lived access token per call. The upstream still applies its own authorization to the real person — so least privilege is still whatever your upstream already enforces — and nothing in the MCP client's configuration holds a secret. **How a person connects.** From the MCP server's page in the KnoxCall console: **Connect my account** → the provider's consent screen → back to KnoxCall. That is the only way, and the reason is the same one that decides identity above: **Nobody can connect on your behalf, and there is no API for it.** Consent has to be given by the person whose credential it is. If an administrator could start a flow naming a colleague and then finish it with their *own* upstream account, the stored connection would hold the administrator's credential under the colleague's name — which is exactly the defect that made `X-KC-User` stop being an identity. `/v1` can **list** and **revoke** connections; it cannot create one. **Set up your OAuth app** with redirect URI `https://oauth.knoxcall.com/callback` — the one KnoxCall publishes, the same for every tenant and every server, because providers match redirect URIs exactly. Ask for the narrowest scopes that do the job: an empty scope list is refused, because most providers read it as "give this app your default grant", which is the widest one they offer. **Rules this mode enforces, and why:** * **The token has to name a person**, exactly as in passthrough. A call whose token names nobody is refused; a person with no connection is refused with a message telling them to connect, not with an upstream error. * **`client_secret_ref` is a secret reference, never a literal.** `auth` is not encrypted and is returned by every read path, so a literal client secret would sit in cleartext in the control plane. A literal is refused with 422. * **Both endpoints must be public `https://`.** Refused at write time, and the connection is dialled through the gateway's resolve-and-pin egress, which validates every DNS answer and refuses redirects — the request carries a live credential. * **PKCE (S256) is always sent**, whatever the client type. The authorization code comes back through a redirect the user's browser follows, and an intercepted code with no verifier is that person's upstream account. * **Refresh is single-flight across the fleet.** Four API workers refreshing one connection at once is how a provider that rotates refresh tokens invalidates three of them and locks the user out of their own account. * **Only the person who started a connection can finish it.** The callback requires a KnoxCall session and refuses if it is not the same user. Without that, someone could start a connection as themselves, send you the link, and have your upstream credential stored under their name — the refusal is audited, and nothing is stored. * **Changing `auth` revokes every connection on the server**, and so does archiving it. The stored tokens were issued for one app, one set of scopes and one upstream; keeping them across a change would mean dialling with tokens minted under settings nobody can see any more. * **Revoking destroys the stored tokens**, it does not set a flag. It does *not* revoke KnoxCall's authorization at the provider — do that in the provider's own account settings if you need it gone there too. * **Connections are never shared between users**, on the same key as passthrough: the pool key carries the token's bound subject and a non-reversible tag of the minted access token, so a refresh opens a new connection rather than leaving an expired token in service. Nobody at KnoxCall, and no administrator in your tenant, can read a stored refresh token: it is decrypted only in the worker that is about to use it, and no API response carries it in any form. ## Getting a token as a person A `tool` token can be minted two ways, and only these two — both prove who you are before minting, because the token speaks for a person once it exists: * **the OIDC exchange**, for a workload (a CI job presenting an id\_token from a bound issuer). Set `subject_claim` on the binding when that workload acts for a named user; * **the device flow**, for a human at a laptop. See [Connect Claude Code, Claude Desktop and Cursor](/ai-gateway/connect-mcp-clients). ## Discovery & audience-bound tokens (OAuth 2.1) A standards-compliant MCP client (Claude Code, Claude Desktop, Cursor) auths without any KnoxCall-specific wiring: 1. **Protected-resource metadata** — [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728). On a `401`, the client fetches: ``` GET https://.knoxcall.com/.well-known/oauth-protected-resource/v1/mcp/ ``` which returns the authorization server to use and the DPoP algorithms this resource accepts: ```json theme={"dark"} { "resource": "https://.knoxcall.com/v1/mcp/", "authorization_servers": ["https://.knoxcall.com"], "bearer_methods_supported": ["header"], "dpop_bound_access_tokens_required": false, "dpop_signing_alg_values_supported": ["ES256"] } ``` `dpop_bound_access_tokens_required` is `false` because DPoP binding is set **per token**, not per resource: a token minted with `dpop_required` must always be presented with a proof, and one minted without it may not use the DPoP scheme at all. Sender-constraining a token is strongly recommended and is what the flow below does — but a resource that returned `true` here would be promising that *every* token it accepts is bound, which is not something this server enforces. 2. **Authorization-server metadata** — [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414). The client reads `https://.knoxcall.com/.well-known/oauth-authorization-server` to find the token endpoint. 3. **Resource-indicated token** — [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707). When exchanging a workload identity for a token at `/v1/oauth/token`, the client passes a **`resource`** naming the exact MCP server: ``` resource=https://.knoxcall.com/v1/mcp/ ``` The minted token is **audience-confined** to that resource — bound into its capability HMAC, so a token issued for `/v1/mcp/weather` cannot be replayed against `/v1/mcp/payments`. The `resource` must name an MCP server on your own domain; a foreign or malformed resource is rejected. Multiple resource indicators are not supported — pass exactly one. ## Registering a server Create one under the gateway's **MCP** tab, or through the API: ```bash theme={"dark"} curl -X POST https://api.knoxcall.com/v1/ai-gateway/gateways/$GATEWAY_ID/mcp-servers -H "Authorization: Bearer $KNOXCALL_KEY" -H "Content-Type: application/json" -d '{ "name": "Vendor tools", "slug": "vendor-tools", "upstream_url": "https://mcp.vendor.example/mcp", "allowed_tools": ["get_weather"], "auth": { "headers": { "Authorization": "Bearer {{secret_id:}}" } } }' ``` The response carries two URLs, and they are **not** the same string: * `connect_url` — where an MCP client points. * `resource` — the RFC 8707 value a token for this server must be bound to (see above). Three rules the API enforces, so they are worth knowing before you hit them: * **`upstream_url` must be a public `https://` address.** Private, loopback, link-local and cloud-metadata destinations are refused when you save the server and again when we connect — the request carries your decrypted upstream credential, so it is never sent in cleartext or to an address inside anyone's network. * **Upstream auth must reference a KnoxCall secret**, e.g. `"Bearer {{secret_id:}}"`. A literal credential is rejected: it would sit unencrypted in the control plane, which is the opposite of what you use KnoxCall for. * **An empty `allowed_tools` advertises nothing.** That is the default, and it is deliberate — a newly registered server exposes no tools until you name them. ## Sessions The gateway keeps an initialized upstream connection alive per session so a client doesn't re-handshake on every call. Two properties are worth knowing: * **A session belongs to one token.** The pool is keyed on the verified capability token, so two tokens never share an upstream connection — nor the decrypted upstream credential behind it — whatever `mcp-session-id` they send. Before 2026-08-25 the client-supplied header was the only separator; see the [changelog](/ai-gateway/changelog). * **A token holds at most 4 concurrent sessions** per server per environment. Opening a fifth closes that token's least recently used one. Idle sessions are closed after 5 minutes. The upstream credential is resolved per connection in the **environment the presenting token names** (`kp_live_` / `kp_test_`), and a request whose environment cannot be proven is refused rather than assumed to be Live. ## Two kinds of MCP server * **Upstream** — proxies an external MCP server (its transport + URL), governed as above. This is what you can create today. * **Collection** — synthesizing MCP tools from a KnoxCall **route collection**, so each governed route becomes a tool executed through the existing proxy. **Not available yet**: the data plane does not serve this type, so creating one is refused with `422 unsupported_server_type` rather than leaving you with a server that fails on every call. **Every tool result is fully inspected.** The gateway talks to your upstream over Streamable HTTP and returns one complete JSON-RPC response per request, so there is no partially-inspected streaming path here — unlike `/v1/ai`, where mid-stream redaction is a real concern with its own [guarantees](/ai-gateway/streaming-guarantees). Redaction covers the whole result and error envelope, recursively: string values, object **keys**, and numbers (a card number encoded as a JSON number does not slip through). # Migrating from Helicone Source: https://docs.knoxcall.com/ai-gateway/migrate-from-helicone Helicone is an observability layer you point a base URL at; KnoxCall is a control plane in the same position. Concept mapping, before/after snippets, and a straight account of what you gain, what you lose, and what to keep running alongside. # Migrating from Helicone Helicone's integration is famously one line — change the base URL, keep your SDK — and KnoxCall's is the same line. That makes the mechanical part of this migration close to trivial and the interesting part entirely about what sits behind it. Be clear about the difference before you start, because it decides whether this is a migration or an addition: **Helicone is an observability layer; KnoxCall is a control plane.** Helicone tells you what happened. KnoxCall decides what is allowed to happen, and then tells you what happened. ## Concept mapping | Helicone | KnoxCall | Notes | | - | - | - | | `Helicone-Auth` header + your provider key | A [capability token](/ai-gateway/tokens-and-dpop) and **no** provider key | The big one — see below | | Custom properties (`Helicone-Property-*`) | Agent `tags` + `X-KC-User` / `X-KC-Team` | Same idea, feeds cost attribution | | User metrics (`Helicone-User-Id`) | `X-KC-User`, resolved against your SCIM directory | | | Caching | [Response cache](/ai-gateway/quickstart#response-caching) — `cache_mode` per agent | Off by default, set over the API (no dashboard control yet). `semantic` exists and is opt-in; we publish no hit-rate figure for it | | Rate limiting | Per-token rate limits + `budget_daily_usd` | Dollars as well as requests | | Request logging | `ai_gateway_usage` + `api_requests` | Bodies are **not** stored unless you turn body logging on | | Prompt / session tracking | *(partial)* | Session-level grouping is on the roadmap, not shipped | | Evaluations, experiments, playground | *(no equivalent)* | Keep Helicone, or another tool, for these | ## Before / after **Before** — your app holds the provider key and Helicone sees it: ```python theme={"dark"} client = OpenAI( api_key=OPENAI_API_KEY, # a real provider key, in your app base_url="https://oai.helicone.ai/v1", default_headers={"Helicone-Auth": f"Bearer {HELICONE_API_KEY}"}, ) ``` **After** — your app holds neither: ```python theme={"dark"} client = OpenAI( api_key=KNOXCALL_CAPABILITY_TOKEN, # not a provider key base_url="https://acme.knoxcall.com/v1/ai/support-bot/v1", ) ``` That is the whole code change. The provider key lives in KnoxCall, envelope- encrypted, and is injected at the egress hop — so it is not in your environment, not in your container, and not in a heap dump. ## What you gain * **Your workload stops holding a provider key.** A leaked capability token is scoped to one agent, one model set, one spend cap and one time window, and can be revoked without rotating anything at the provider. * **[Mid-stream PII redaction](/ai-gateway/streaming-guarantees).** Entities are removed inside the SSE stream — including ones split across two frames — without buffering the response and losing streaming. * **[A prompt firewall](/ai-gateway/firewall) that can refuse**, plus [your own scanner](/ai-gateway/guardrail-webhook) if you already run one. * **Spend caps that actually stop a call**, shared across workers rather than counted per process. * **[Routing and fail-over](/ai-gateway/routing-and-failover)** — retries on 429 honouring `Retry-After`, weighted load balancing across providers. ## What you lose, stated plainly Helicone does things we do not, and pretending otherwise would make the rest of this page less trustworthy: * **Evaluations and experiments.** No equivalent. If you run prompt experiments through Helicone, keep doing that. * **Prompt management and versioning.** No equivalent. * **Session-level grouping.** Our usage rows are per call, attributable to a user and a team, but multi-turn session rollups are not shipped yet. * **Its dashboard.** Ours is built around cost, protection outcomes and audit rather than around request exploration. Running both is entirely reasonable: point your SDK at KnoxCall and forward its logs onward, or keep Helicone on the experiments you are actively running while production traffic goes through the gateway. ## A word on timing Mintlify acquired Helicone (announced 3 March 2026), and [Helicone's own announcement](https://www.helicone.ai/blog/joining-mintlify) says its services stay live in maintenance mode: security updates, new models, and bug and performance fixes keep shipping. We are not going to build a migration argument on a competitor's health — that ages badly, and it is the sort of claim that turns out to be wrong in public. Check the repository and their own communications yourself; if it is thriving, migrate for the reasons in "What you gain", or do not migrate at all. ## Suggested order 1. Create one agent per model-and-purpose, with a secret holding the provider key. 2. Mint one capability token per calling service, scoped to the models that service uses. 3. Swap the base URL and drop the `Helicone-Auth` header and the provider key. 4. Rotate the provider key at the provider — it has been in your application's environment, so treat it as exposed. 5. Turn on redaction and a firewall policy in `warn`, read the events for a few days, then move to `block`. Step 4 is the one people skip. The reason to move the key into custody is that it stops being reachable from your workload — which does nothing for a key that has already been sitting in a container's environment for a year. # Migrating from LiteLLM Source: https://docs.knoxcall.com/ai-gateway/migrate-from-litellm Point knoxcall ai import at your existing LiteLLM config.yaml and get a dry-run plan: one agent per model_name, load-balanced deployments collapsed into weighted routes, retries and budgets carried across, and your model names preserved as gateway aliases. # Migrating from LiteLLM LiteLLM's proxy and KnoxCall's AI Gateway sit in the same place in your stack and solve overlapping problems, so most of a `config.yaml` maps across mechanically. There is a command for that. ```bash theme={"dark"} npm i -g @knoxcall/sdk # the CLI ships with the Node SDK knoxcall login knoxcall ai import --from litellm --file ./config.yaml ``` That is a **dry run**. It reads the file, prints exactly what it would create, and writes nothing — so it needs no sign-in. `--apply` is the deliberate second step, and it acts as the user signed in with `knoxcall login`. `ai import` is the one `knoxcall ai` sub-command that exists only in the **Node** CLI. Everything else (`gateways`, `agents`, `create-agent`, `mint`, `usage`, `exchange`) is identical in all five SDKs. The importer carries a hand-rolled YAML-subset parser — `knoxcall` ships with zero runtime dependencies — and four more hand-ports of a YAML parser would be four more places for the plan to disagree with itself about what your config means. One implementation, in the CLI every platform can run. ## What maps onto what | LiteLLM | KnoxCall | | - | - | | `model_list[].model_name` | one agent, slugified — and a gateway **model alias** so your clients keep working | | `litellm_params.model` (`openai/gpt-4o`) | the agent's `provider` + `default_model` | | `litellm_params.api_base` | the agent's `upstream` (SSRF-checked at create) | | several entries sharing one `model_name` | **one** agent with weighted routes | | `litellm_params.weight` / `rpm` | that route's weight | | `router_settings.num_retries` | `routing_policy.max_attempts` (+1 — we count attempts, they count retries) | | `litellm_settings.max_budget` + `budget_duration` | `budget_daily_usd` or `budget_monthly_usd` | ### Load balancing collapses into one agent This is the mapping worth understanding, because it changes your topology for the better. In LiteLLM, listing the same `model_name` twice **is** the load balancer: ```yaml theme={"dark"} model_list: - model_name: fast litellm_params: { model: groq/openai/gpt-oss-120b, weight: 3 } - model_name: fast litellm_params: { model: together_ai/openai/gpt-oss-120b, weight: 1 } ``` KnoxCall expresses that as one agent with two weighted routes, so it needs **one** capability token rather than two, and one budget rather than two that have to be kept in step. See [Routing, Retries & Fail-over](/ai-gateway/routing-and-failover). ### Your model names survive Existing callers ask for `"model": "fast"`. Rather than make you edit every call site on day one, the importer writes a gateway-level **model alias** mapping `fast` → the real upstream id. Aliases resolve *before* the per-agent model policy, so an alias target still has to pass that agent's allow/deny list. ## What it will not do: your keys The importer **never reads an API key out of your config**, and never transmits one. It records how each entry referenced its credential and leaves you to create the KnoxCall secret: ``` credential (yours to create): os.environ/OPENAI_API_KEY ``` If an entry carries a **literal** key rather than an `os.environ/` reference, the importer says so and does not read it — and tells you to treat that key as exposed, because it is sitting in a file. Rotate it at the provider when you create the KnoxCall secret. That is not squeamishness. A migration tool that quietly ingests a plaintext key from a checked-in config has copied that key into a second system, which is an odd thing for a credential-custody product to do to you on your first day. ## What it will not do: guess Two refusals you may hit, both deliberate: **An unprefixed model.** LiteLLM treats `model: gpt-4o` with no provider prefix as OpenAI. The importer will not inherit that default for you — pointing an agent at the wrong provider with the wrong key is a worse outcome than a warning. Prefix it (`openai/gpt-4o`) or set the provider after import. **A budget duration we do not have.** KnoxCall has a daily cap and a monthly cap. `budget_duration: 7d` is neither, so the budget is **not** imported and the report says so. Dividing by seven to fake a daily rate would put a number in front of you that you never chose. ## The YAML it can read The importer parses a YAML subset — nested maps, lists of maps, scalars, simple inline lists — which covers the LiteLLM configs people actually have. It has no third-party YAML dependency, because `knoxcall` ships with **zero** runtime dependencies and a credential-handling CLI is the wrong place to grow a transitive tree. Anything outside that subset — anchors, aliases, merge keys, block scalars, multi-document files — is a hard error naming the line: ``` config.yaml: line 14: block scalars (| and >) are not supported ``` It refuses rather than skips, on purpose. An importer that silently dropped a `model_list` entry would hand you a plan that looks complete and is not; you would migrate, believe you were done, and find the missing model in production. Being told about line 14 costs a minute. ## Applying it This step, and the `create-agent` and `mint` commands after it, act as the user signed in with `knoxcall login`. ```bash theme={"dark"} knoxcall ai import --from litellm --file ./config.yaml --apply ``` Creates the gateway, the agents and the model aliases. Exit codes: `0` success, `1` something failed while applying (partial creation is possible, and every failure is named so you can retry just those), `2` the input was unusable and nothing was written. Then, for each agent, attach its provider key — the importer never read one, so this is the step that gives the agents an upstream. The key comes from your environment, never from a flag: ```bash theme={"dark"} export OPENAI_API_KEY=sk-... knoxcall ai create-agent --slug gpt-4o --provider openai --secret-from-env OPENAI_API_KEY knoxcall ai mint --agent ``` At that point you have what LiteLLM gave you, plus per-agent capability tokens, mid-stream PII redaction, a prompt firewall, and per-call attribution. ## What you gain that has no LiteLLM equivalent Worth knowing before you plan the cutover, because these are the reasons to do it rather than the mechanics: * **Your app never holds a provider key.** It holds a KnoxCall capability token scoped to a provider, a model set, a spend cap and a time window. The real key is injected at the egress hop. See [Capability keys](/ai-gateway/tokens-and-dpop). * **[Mid-stream PII redaction](/ai-gateway/streaming-guarantees)** — entities are removed inside the SSE stream, including ones split across two frames, without buffering the response. * **[A prompt firewall](/ai-gateway/firewall)** that can refuse a request, and [your own scanner](/ai-gateway/guardrail-webhook) if you already have one. * **Per-call attribution and audit** — which token, which user, which team, what it cost. One thing to check before you cut over: LiteLLM supports more providers than KnoxCall's fourteen. Run the dry run first and read the warnings — anything that could not be mapped is named there, and an entry with an `api_base` will usually map as `openai-compatible` even when its prefix is unknown to us. # Migrating from Portkey Source: https://docs.knoxcall.com/ai-gateway/migrate-from-portkey Concept-by-concept mapping from Portkey's virtual keys, configs and gateway to KnoxCall agents, routing policies and capability tokens — with before/after snippets and an honest account of what does not map. # Migrating from Portkey Portkey and KnoxCall overlap on the gateway itself — one endpoint in front of many providers, with routing, fallbacks and observability — and diverge on where the provider credential lives and what happens to the data passing through. There is no automated importer for Portkey today (its configuration lives in a hosted control plane rather than a file you can hand us), so this is a concept-by-concept mapping you apply by hand. It is usually an afternoon. ## Concept mapping | Portkey | KnoxCall | Notes | | - | - | - | | Virtual key | Agent + its upstream secret | The agent is the thing you point an SDK at; the secret holds the provider key | | API key / auth header | [Capability token](/ai-gateway/tokens-and-dpop) | Scoped to provider, model set, spend cap, IP range and time window, HMAC'd into the token | | Config (routing) | [`routing_policy`](/ai-gateway/routing-and-failover) on the agent | Retries, backoff, weights | | Config (fallback targets) | `fallback_route_ids` + `route_weights` | Fail-over order, or weighted split | | Config (retries) | `routing_policy.retry_on` + `max_attempts` | `429` must be named explicitly — see below | | Metadata | Agent `tags` + `X-KC-User` / `X-KC-Team` headers | Feeds per-user and per-team cost attribution | | Guardrails | [Prompt firewall](/ai-gateway/firewall) + [PII detectors](/ai-gateway/pii-redaction) + [your own scanner](/ai-gateway/guardrail-webhook) | | | Budget / usage limits | `budget_daily_usd`, `budget_monthly_usd` | Shared across workers, not per-process | | Prompt templates | *(no equivalent)* | Keep them in your application | ## Before / after **Before** — Portkey-style, provider selection in a header or config id: ```python theme={"dark"} from openai import OpenAI client = OpenAI( base_url="https://api.portkey.ai/v1", api_key=PORTKEY_API_KEY, default_headers={"x-portkey-config": "pc-my-config-id"}, ) client.chat.completions.create(model="gpt-4o", messages=[...]) ``` **After** — the agent *is* the config, so the base URL carries it: ```python theme={"dark"} from openai import OpenAI client = OpenAI( base_url="https://acme.knoxcall.com/v1/ai/support-bot/v1", api_key=KNOXCALL_CAPABILITY_TOKEN, ) client.chat.completions.create(model="gpt-4o", messages=[...]) ``` Nothing else in your code changes. The routing, the budget, the firewall and the redaction all hang off `support-bot`, which means they are the same for every caller of that agent rather than dependent on a header any caller can change. ## Three things that will surprise you **A 429 is returned to you by default.** Portkey retries on rate limits out of the box. KnoxCall does not, unless the agent's `routing_policy` names `"429"` in `retry_on` — because silently absorbing a rate limit into a second provider's bill is not a decision we make on your behalf. Set it explicitly: ```json theme={"dark"} { "routing_policy": { "retry_on": ["429", "5xx"], "max_attempts": 3 } } ``` **Your app stops holding a provider key entirely.** A Portkey virtual key still resolves to a provider credential that the gateway sends onward; the difference here is what your workload holds. A KnoxCall capability token is not a provider credential and cannot be replayed against the provider — it only works against your agent, and only within its scope. **Prompt templates have no home here.** If you use Portkey's prompt management, that stays in your application or in whichever tool you prefer. We are a gateway, not a prompt store, and pretending otherwise would leave you with half a feature. ## What to do about the acquisition Palo Alto Networks closed its acquisition of Portkey on 29 May 2026 ([press release](https://www.paloaltonetworks.com/company/press/2026/palo-alto-networks-completes-acquisition-of-portkey-to-secure-ai-agents)) and now sells it as Prisma AIRS AI Gateway. Whether that is good or bad for you depends on whether you wanted a security-platform relationship, and it is not our place to tell you it is bad. The practical question for a migration is continuity of the specific features you rely on and of your commercial terms — worth asking them directly before you plan around either. ## Suggested order 1. Create one agent per Portkey config, with its provider and a secret holding that provider key. 2. Mint one capability token per calling service — not one shared token. Scope each to the models that service actually uses. This is the step that pays for the migration. 3. Move `retry_on` / weights across into `routing_policy`. 4. Run both gateways in parallel behind a feature flag; compare `ai_gateway_usage` against your Portkey analytics for a week. 5. Turn on [PII redaction](/ai-gateway/pii-redaction) and a [firewall policy](/ai-gateway/firewall) in `warn` first, read the events for a few days, then move to `block`. Step 5 in `warn` first is genuine advice, not a hedge. Turning a blocking control straight on in front of production traffic is how a good control gets switched back off after one bad afternoon. # Model & Output Policy Source: https://docs.knoxcall.com/ai-gateway/model-and-output-policy Constrain which models an agent may call (allowlist, denylist, rewrite map, default model) and validate the shape of what comes back against a JSON Schema — block, retry, or warn. # Model & Output Policy Two agent-level controls that bracket the call: **model policy** decides what may go upstream, **output validation** decides whether what came back is usable. ## Model policy Four fields on the agent, all optional: | Field | Type | Effect | | - | - | - | | `default_model` | string | Substituted when the request body carries no `model`. | | `model_rewrite` | map | Exact source-model → target-model substitution. | | `model_denylist` | string\[] | Patterns that are refused. | | `model_allowlist` | string\[] | When non-empty, the model must match one. | ### Evaluation order The order matters, because a rewrite changes what the lists see: 1. **No `model` in the body?** If `default_model` is set it is written into the body and the request proceeds. If it isn't, nothing is enforced — the request passes through and the upstream decides (most reject a missing model). 2. **`model_rewrite`** — an *exact* key match on the requested name substitutes the target. The rewritten name is what the lists then match, so you cannot smuggle a denied model in by rewriting to it. 3. **`model_denylist`** — a match refuses the request. **Denylist beats allowlist**; a model on both lists is denied. 4. **`model_allowlist`** — when the list is non-empty, a model that matches nothing in it is refused. An empty allowlist means "no allowlist", not "allow nothing". ### Patterns Allowlist and denylist entries are globs: `*` matches any run of characters, including dashes, anywhere in the name — `claude-*` matches every Claude model, `*-opus-*` matches by family. `?` is a **literal question mark**, not a wildcard. `model_rewrite` is not a pattern language: its keys are exact model names. Patterns are compiled with the platform's linear-time regex engine, so a pathological pattern cannot stall a worker. **Both lists fail closed, in opposite directions.** A pattern the engine refuses to compile counts as a **match** on the denylist (so an unreadable deny rule blocks rather than silently stopping denying) and as a **non-match** on the allowlist (so an unreadable allow rule grants nothing). Either way the request is refused, never quietly let through. Check your patterns on save rather than discovering this in production. ### Responses A refused model gets a `403` naming the reason and the patterns it could have matched: ```json theme={"dark"} { "error": "model_denylisted", "error_description": "Requested model is not permitted by this agent's policy", "requested_model": "gpt-4o", "allowed": ["claude-sonnet-*", "claude-haiku-*"] } ``` `error` is `model_denylisted` or `model_not_allowed`. `allowed` is `null` when the refusal came from the denylist rather than the allowlist. Every data-plane refusal uses this `{ error, error_description }` shape — it is **not** the `{data, meta}` envelope the `/v1` management API returns, because the data plane speaks your provider SDK's protocol, not KnoxCall's. A rewrite or a default substitution is silent to the caller — the request succeeds — but is recorded on the usage row, so the model you are billed for is the model that actually ran. ### Day-zero models Model policy is opt-in. With no allowlist an agent will proxy any model its upstream accepts, which is what makes a new model usable the day it ships — see [Providers & day-zero models](/ai-gateway/day-zero-models). ## Output validation Set an agent's `output_schema` to a JSON Schema and the gateway validates the model's answer against it before the response leaves the building. ### What is validated The gateway extracts the assistant's text content from the provider response, parses it as JSON and checks it. The supported schema subset is deliberately small — `type`, `required`, `properties`, `items`, `enum` — which covers "return this exact object shape" without a full JSON-Schema engine. Two cases pass without complaint by design: * a response with no extractable text content (nothing to validate); * content that is not JSON, **unless** the schema's top-level `type` is `object` or `array` — then non-JSON is a violation. ### Actions `output_validation_action` decides what happens on a violation. Default is `warn`. | Action | Behaviour | | - | - | | `warn` | The response is returned to the caller unchanged, with `X-Knox-AI-Output-Warning` carrying the first errors (truncated to 200 characters). | | `block` | The caller gets `422 output_schema_violation` with the first few errors in `error_description`. The upstream call already happened and is still billed. | | `retry` | The gateway re-calls the upstream **once**, appending the model's own answer plus a corrective instruction naming the schema errors. If the retry succeeds it becomes the response and `X-Knox-AI-Retry: output_schema` is set. If it fails or violates again, the original response is returned **with the `warn` header**. | A `warn` violation is reported to the caller in a response header only. It does not write an audit row or a queryable event, so "how often does this agent break its schema" has no answer on the server side today — read the header in your client if you need to count them. A `retry` costs a second upstream call, charged like any other. It is worth it for a flaky structured-output model and not worth it for a model that never gets the shape right — fix the prompt instead. **Output validation runs on the buffered path only.** A streaming request (`stream: true`) is forwarded token by token and there is no complete document to validate until the client already has it, so `output_schema` is not applied. If the response shape is load-bearing for your application, turn streaming off for that agent (`streaming_enabled: false`) rather than assuming the schema is being enforced. A retried response goes through exactly the same downstream stages as a first response — [PII redaction and detokenization](/ai-gateway/pii-redaction), the canary scan, the cache-store gate — so a retry cannot be a way around a control that applied to the first answer. # Observability Source: https://docs.knoxcall.com/ai-gateway/observability Per-request OpenTelemetry spans with GenAI semantic conventions plus KnoxCall AI-Gateway attributes — export to your own backend (Langfuse, Braintrust, Datadog, …). No lock-in, no proprietary eval store. # Observability KnoxCall is **OTel-native** for AI observability: every gateway call emits an OpenTelemetry span using the GenAI semantic conventions, plus KnoxCall-specific attributes for the guardrail outcomes. Point it at your own collector and use whatever tracing/eval backend you already run — Langfuse, Braintrust, Datadog, Honeycomb, and so on. There is no proprietary eval store to lock you in. ## Enabling export Configure the OpenTelemetry OTLP endpoint on the operator **observability** Integration (KnoxCall's config lives in the admin UI, not environment files). Once an endpoint is set, spans export automatically; until then, tracing is off. ## Span attributes | Attribute | Value | | - | - | | `gen_ai.system` | Provider (e.g. `anthropic`, `openai`) | | `gen_ai.request.model` | Requested model | | `gen_ai.usage.input_tokens` | Input token count | | `gen_ai.usage.output_tokens` | Output token count | | `knoxcall.ai_gateway.firewall_outcome` | `pass` / `warn` / `block` / `tag` | | `knoxcall.ai_gateway.pii_tokens_swapped` | Count of PII redactions on the call | | `knoxcall.ai_gateway.cost_usd` | Estimated cost in USD | ## Security and cost events you can alert on Spans are for tracing a call. The events below are written to your **audit log**, which is where you set alert rules — `POST /admin/alerts` with `alert_type: "ai_gateway_event"` and the action in `condition_json.audit_action`. See [alert rules](/monitoring/alerts) for the rule shape. | Action | Severity | Raised when | | - | - | - | | `ai_gateway.request_denied` | varies | **Any** refusal, on either plane, with the specific cause in the row's details. One action for every denial is deliberate: a new refusal reason cannot invent an action nobody is alerting on. This is the row to alert on if you only alert on one. | | `ai_gateway.canary_leak` | critical | A [canary token](/ai-gateway/firewall#canary-tokens) injected into a system prompt came back in a model's response — a strong signal the system prompt was extracted. | | `ai_gateway.pii_tokenize_failed` | critical | Outbound PII tokenization failed, so the request was **refused** rather than forwarded untokenized. The raw value did not leave. | | `ai_gateway.geo_velocity_anomaly` | scaled | A token was presented from two locations too far apart to be plausible. Severity scales with how implausible the travel is; see [impossible-travel detection](/ai-gateway/tokens-and-dpop#impossible-travel-detection-and-step-up). | | `ai_gateway.spend_spike` | high | A daily spend well above the agent's own robust baseline. See [spend-spike detection](/ai-gateway/budgets-finops#spend-spike-detection). | | `ai_gateway.unpriced_model` | — | A model was observed that is not in the pricebook, so its calls are recorded `cost_source = 'unpriced'` rather than booked as free. Update the manifest deliberately. | | `secret_share.domain_violation` | — | A linked secret's domain lock refused an upstream. Written for **both** the borrowing and the lending tenant. | Configuration changes are audited too, with the same `ai_gateway.` prefix: `gateway_create` / `_update` / `_archive`, `agent_create` / `_update` / `_archive`, `token_mint`, `token_revoke`, `firewall_policy_create` / `_update` / `_delete`, `mcp_server_create` / `_update` / `_archive`, and `mcp_tool_upsert` / `_update` / `_delete`. `ai_gateway_event` is not yet in the admin UI's alert-type picker, so these rules have to be created through the API today. ## Why OTel instead of a built-in eval product Evals belong where your traces already live. By exporting standard GenAI spans, KnoxCall lets you run evaluation, scoring, and dashboards in the tool your team has chosen, with the guardrail outcomes (firewall verdict, PII redaction count, cost) already attached to each trace — rather than trapping that data in a KnoxCall-only view. # AI Gateway Overview Source: https://docs.knoxcall.com/ai-gateway/overview A security-hardened, provider-neutral proxy for your AI traffic: phantom-token auth, true mid-stream PII redaction, prompt firewall, budgets, and unified cost attribution — with a one-line base-URL swap. # AI Gateway Overview The **AI Gateway** is KnoxCall's AI-native egress proxy. It sits between your applications (or agents) and upstream AI providers, and enforces governance and safety on every call — without your code ever holding a raw provider key. You adopt it with a **one-line base-URL swap**: point your existing Anthropic or OpenAI SDK at your agent's gateway URL and use a short-lived KnoxCall **phantom token** as the API key. Everything else — budgets, PII redaction, the prompt firewall, cost attribution, audit — happens transparently on the wire. ```text theme={"dark"} Your app / agent ──▶ AI Gateway ──▶ Anthropic / OpenAI │ ├─ phantom-token auth (no raw provider key in your app) ├─ prompt firewall (injection heuristics + canary) ├─ streaming PII redaction (reversible tokenization) ├─ budget enforcement (USD caps, per agent) └─ usage + cost attribution (per user / team / model) ``` ## Why it exists Handing a raw provider key to an application — or worse, to an autonomous agent — means an unscoped, long-lived, un-revocable credential with no per-call governance. The AI Gateway replaces that with a credential and a control plane designed for AI traffic: * **Your app never holds a provider key.** It holds a short-lived, scoped, revocable [phantom token](/ai-gateway/tokens-and-dpop). The real provider key lives in a KnoxCall-managed secret, injected by the route at egress. * **Guardrails run mid-stream, not after the fact.** KnoxCall's [PII redaction](/ai-gateway/pii-redaction) rewrites sensitive data inside the SSE stream as it flows — before it reaches the client — and can restore it on the response. Most gateways can only log or buffer. * **Cost is attributable and enforceable.** Every call is priced and recorded against a user, team, model, and agent, and [budgets](/ai-gateway/budgets-finops) can warn or hard-block before overspend. ## What you get | Capability | What it does | | - | - | | **Phantom tokens** | Short-lived capability tokens, optionally [DPoP](/ai-gateway/tokens-and-dpop)-bound (sender-constrained). Your app never holds the provider key. | | **Streaming PII redaction** | Detector stack (Aho-Corasick + regex/checksum + optional [Presidio](/ai-gateway/pii-redaction)) with reversible tokenization, applied inside the live stream. | | **Prompt firewall** | Built-in prompt-injection heuristics + your own regex/keyword rules + [canary tokens](/ai-gateway/firewall) that detect system-prompt extraction. | | **Budgets & FinOps** | Per-agent USD caps with warn/block actions; [usage attribution](/ai-gateway/budgets-finops) by user, team, model, and agent. | | **Compliance packs** | One-click [HIPAA, PCI-DSS, GDPR, SOC 2](/ai-gateway/compliance-packs) recognizer + alert-rule bundles. | | **Observability** | Per-request [OpenTelemetry GenAI spans](/ai-gateway/observability) — export to your own backend; no lock-in. | ## Core concepts * **Gateway** — a container for agents, scoped to your tenant. You can have several (e.g. one per product surface). * **Agent** — the addressable unit at `/v1/ai//…`. An agent has a primary **route** (which upstream provider + injected key), a firewall policy, a PII policy, a budget, and mint-able tokens. * **Route** — a standard KnoxCall route pointing at the provider (e.g. `api.anthropic.com`), injecting the provider key from a secret. The gateway reuses KnoxCall's whole proxy substrate — secret injection, sandbox, rate limits, audit. * **Phantom token** — the credential your app sends. Minted for one agent, short-lived, scoped, revocable. ## Bring your own Anthropic key AI features that use Anthropic (including the gateway route KnoxCall provisions for first-party AI) run on **your own Anthropic key**, configured under **Settings → Integrations → Anthropic** — never a shared KnoxCall key. See [Bring your own key](/ai-gateway/byo-key). ## Next steps Swap your base URL and make your first governed call in minutes. How mid-stream redaction and reversible tokenization work. Phantom-token kinds, scoping, and sender-constrained credentials. Enforce spend caps and attribute cost by user, team, and model. # PII Redaction Source: https://docs.knoxcall.com/ai-gateway/pii-redaction True mid-stream PII redaction with reversible tokenization: a sliding hold-back FSM rewrites sensitive data inside the live SSE stream, and can restore it on the response. # PII Redaction Most gateways can only inspect an AI response *after* it finishes (buffering) or *log* what they saw (monitoring). KnoxCall redacts **inside the live stream**: as SSE frames flow from the provider to your app, sensitive spans are rewritten before they leave the gateway. ## How it works ### The detector stack Each candidate text span is screened by a layered detector stack, fastest first: 1. **Aho-Corasick** multi-pattern scan for known literals and keywords. 2. **Regex + checksum** validators for structured identifiers — credit cards (Luhn), IBAN, US SSN, email, E.164 phone numbers, JWTs, and known provider API-key shapes. 3. **[Presidio](#presidio-optional)** (optional) — a named-entity recognizer sidecar for names, locations, and other NER-based entities. Higher-precision tiers win overlap ties, so a span matched by a checksum validator is not double-counted by a looser NER match. ### Streaming hold-back Streaming redaction uses a **sliding hold-back buffer**: the gateway holds back the last N characters of the stream (default 96) so a sensitive token that straddles two SSE frames is still caught before any part of it is emitted. The buffer is SSE-frame-aware for the Anthropic, OpenAI and Gemini event shapes, so redaction never corrupts the event framing. The held-back characters are released when the provider marks the block finished — just before Anthropic's `content_block_stop`, before OpenAI's `finish_reason` chunk (and always before `data: [DONE]`), and inside Gemini's `finishReason` chunk — so a client that stops reading at the provider's end marker has already received the whole answer. ### Streamed tool calls A tool call's **arguments** are model output too, and they often carry values lifted straight from the prompt. They are scanned on the streaming path as well: OpenAI `tool_calls[].function.arguments` (and the older `function_call`), Anthropic `input_json_delta`, and Gemini `functionCall.args`. Arguments stream as JSON fragments a few characters at a time, and a fragment cannot be rewritten without breaking the JSON your client assembles. So the gateway **holds each call's arguments until the call is finished**, scans every key and value in the complete document, and releases it as one fragment of the same call. What changes for you: * The call's first chunk — its `id` and function `name` — still arrives when the model starts the call. The arguments arrive **all at once, when the call finishes**, rather than character by character. A UI that renders arguments as they stream will show them appear in one step. * Arguments with nothing detected are passed through **byte for byte**. When something is redacted, the document is re-serialised (compact whitespace). * Under `pii_response_mode: "detokenize"`, `KC_*` placeholders inside arguments are restored like the rest of the response, so the tool you run receives your original values. * A system-prompt canary in the arguments is caught before any of them leave; under a `block` firewall policy the stream ends with an `event: error` frame (`canary_leak`). * One call's arguments are held up to 4,194,304 characters. A call larger than that ends the stream with an `event: error` frame whose `code` is `tool_arguments_too_large`, rather than being released unscanned. In `monitor` streaming mode arguments stream through unchanged, as everything does in that mode, and each call is scanned for the record once it finishes. ### The two directions Redaction is **two independent settings**, because they answer two different questions. Attaching a PII policy chooses *what* is detected; these choose *what happens* to it. | Setting | Values | Default | What it decides | | - | - | - | - | | `pii_request_mode` | `tokenize` \| `off` | `tokenize` | What happens to the **prompt** before it leaves KnoxCall. | | `pii_response_mode` | `detokenize` \| `redact` | `detokenize` | What happens to the provider's **answer** on the way back. | `pii_request_mode: "off"` is the only configuration in which the upstream provider receives a detected value. It exists for agents whose payload has to arrive byte-for-byte — structured tool-use JSON, for instance, where rewriting a field would corrupt the tool arguments — and it is a per-agent column so the choice is visible in the API, the dashboard and a compliance export. `pii_response_mode: "redact"` still runs the full response-side detector stack: personal data the *model* produced is redacted, and the canary check still fires. It differs from `detokenize` only in leaving `KC_*` placeholders in place rather than restoring your originals. ### Knowing whether the answer was changed When the response-side detectors change a **non-streamed** answer, the response carries `X-Knox-AI-Pii-Rewritten: `, where `` is the number of replacements that actually altered the text. Its presence means the body you received is **not** the provider's verbatim answer. * A match that leaves the text unchanged — a monitor-only or allow-listed detection — is not counted, so detection can find something and the header still be absent. * On a non-streamed response, absence means no detector replacement changed the answer. **A streamed response never carries this header, even when it was rewritten.** Headers are sent before the first byte of the body, and streamed redaction happens mid-stream (see [Streaming hold-back](#streaming-hold-back)), so the count does not exist yet when the headers go out. On a stream, the header's absence tells you **nothing** — do not read it as "unmodified". What was redacted on a streamed request is recorded in [PII events](#pii-events). Until 2026-09-07 these were **one** boolean, `pii_detokenize_response`, and it was the response-side one. Request-side tokenization was gated on it, so an agent with a PII policy attached and `pii_detokenize_response: false` loaded its recognizers, validated them, refused on a degraded policy — and then sent the prompt to the provider verbatim. Existing agents were migrated to `pii_request_mode: "tokenize"`, including the ones that had detokenize off: prompts on those agents are now tokenized where they previously were not. `pii_detokenize_response` is still accepted on the API and is now a read-only mirror of `pii_response_mode == "detokenize"`. ### Streaming: what has a mid-stream redactor, and what refuses The hold-back rewriter can only splice a stream it can parse. KnoxCall ships one for the **Anthropic, OpenAI and Gemini** SSE shapes. It has none for **Cohere's** native SSE shape, and none for **Bedrock**, which streams length-prefixed `application/vnd.amazon.eventstream` binary frames rather than SSE at all. A streamed request on one of those two is **refused with 400 `streaming_redaction_unsupported`**, not served. Streaming it would hand the client a response with no response-side redaction, no detokenization and no canary scan — the exact controls the buffered path applies on every 2xx. Send the request without `stream: true`. The same refusal applies **per endpoint** on a provider that does have a redactor. Each redactor reads one event family, so streaming is offered only where that family is what the provider sends: | Wire shape | Endpoint that streams | Refused when streamed | | - | - | - | | Anthropic | `…/v1/messages` | anything else | | OpenAI and OpenAI-compatible | `…/chat/completions` | the Responses API (`/v1/responses`), legacy `/v1/completions`, anything else | | Gemini | `…:streamGenerateContent` (the gateway adds `?alt=sse`) | anything else | What counts as a streamed request is decided by the **request**: `stream: true` in the body, or Gemini's `:streamGenerateContent` endpoint. An `Accept: text/event-stream` header on its own asks nothing of the provider, so the response is served on the buffered path and fully redacted. An agent with `streaming_enabled: false` refuses a request that asks to stream (`400 streaming_disabled`) — the provider would stream regardless, and a streamed body cannot be redacted on the buffered path. And a streamed request that the provider answers with something other than an event stream is refused (`502 upstream_stream_shape_unexpected`) rather than forwarded unread. ### A body the gateway cannot read is refused Tokenization walks the prompt fields of a JSON body. A non-JSON body has no prompt fields to walk, so on an agent with `pii_request_mode: "tokenize"` it is **refused with 400 `pii_unscannable_body`** rather than forwarded. "We found no personal data" and "we could not look" are different answers, and only one of them belongs in a compliance record. Set `pii_request_mode: "off"` if the agent genuinely needs to forward opaque payloads. ### Reversible tokenization When a policy uses reversible tokenization, each detected span is replaced with a stable placeholder token, and the original value is stored in a per-conversation **encrypted token map** (`ai_gateway_pii_token_map`). On the response path, the gateway can **detokenize** — restoring the original values for the end user while keeping them out of the provider's logs and out of any [cache](/ai-gateway/observability). Scope the map across turns with the `X-KC-Conversation-Id` request header, so a value tokenized on turn 1 restores consistently on turn 5. Send a **UUID** and it is used as the map's id exactly as given. Send **any other string** — `support`, `case-4192` — and it is treated as a name and scoped to the **phantom token that sent it**: two tokens using the name `support` get two separate maps, so one caller's values can never restore into another's. If you want several tokens to share one map, generate a UUID and send that. #### Inspecting a conversation's token map For debugging, an **Owner** or an **Admin** can list a conversation's tokens with `GET /admin/ai-gateway-pii/token-map/{conversationId}` — the conversation's UUID, or the name a client sent plus `?principal=`. Each entry shows the token, the first three characters of the original value and its length; the full value never leaves the gateway. Three characters and an exact length still narrow a card number, an SSN or an email a long way, so this view is a **reveal**: every request asks for a fresh second factor (an authenticator code, a passkey or a security key; never an emailed code), writes a `plaintext.reveal` entry to the audit log naming the conversation **before** anything is decrypted, and counts against the same per-person reveal budget as showing a secret's value. If the audit entry cannot be written, or the budget cannot be counted, the request is refused rather than served. Editors and Viewers cannot use it, and a platform operator who is not a member of your workspace is refused. Because reversible tokenization keeps a per-conversation map, it interacts with caching: semantic caching is disabled while reversible tokenization is active for a conversation, to avoid leaking one conversation's values into another. ## Measured detection quality We measure the detector stack against a committed labeled corpus rather than describing it. The corpus (`tests/fixtures/guardrail-eval/`) holds 38 documents carrying 70 labeled identifiers, plus 28 **hard negatives** that contain no personal data at all - UUIDs, git object ids, non-Luhn order numbers, stack traces, Kubernetes manifests, an IBAN with a wrong check digit. The numbers below are produced by `npm run ai-gateway:eval`, pinned in `tests/coverage/baselines/guardrail-eval.json`, and enforced on every CI run by a ratchet: recall may only rise, and the false-positive rate may only fall. Measured 2026-08-25, in-process stack (Aho-Corasick + regex/checksum), no Presidio: | Entity | Labeled | Precision | Recall | | - | - | - | - | | US SSN | 5 | 100% | 100% | | Payment card (Luhn) | 6 | 100% | 100% | | IBAN (mod-97) | 3 | 100% | 100% | | Email address | 8 | 80% | 100% | | E.164 phone | 4 | 100% | 100% | | JWT | 2 | 100% | 100% | | Anthropic API key | 3 | 100% | 100% | | OpenAI API key | 2 | 100% | 100% | | AWS access key id | 2 | 100% | 100% | | Google API key | 1 | 100% | 100% | | Stripe secret/restricted key | 2 | 100% | 100% | | GitHub token | 2 | 100% | 100% | | Canary token | 2 | 100% | 100% | | Tenant custom literal | 3 | 100% | 100% | Every detected span had **exact** boundaries: nothing was over- or under-redacted. **False positives on clean text: 1 of 28 hard-negative documents (3.6%).** The one failure is worth naming, because it is the failure mode that costs you something: the email pattern matches machine identities, so `git@github.com` and `deploy@build-runner-04.internal` in a pasted shell command are redacted. If you send code through an agent with redaction on, expect that. ### What this stack does not detect The in-process tiers cover structured identifiers with a deterministic shape. They do **not** detect names, street addresses, medical record numbers, national provider or DEA identifiers, passport numbers, driver licences, IP addresses or crypto wallet addresses. Those entities are in the corpus too, and score 0% against this stack - deliberately, so the gap is a published number rather than an omission. Names and locations need the [Presidio](#presidio-optional) tier; the rest are covered by [compliance packs](/ai-gateway/compliance-packs) once a pack is installed. ### Prompt-injection firewall The [firewall](/ai-gateway/firewall) heuristics are measured the same way, over 24 injection attempts and 16 benign prompts. Measured 2026-08-25: **recall 58%, precision 67%**. The built-in rules catch direct override phrasings ("ignore all previous instructions", "your new instructions are", named jailbreaks) and miss obfuscated, translated, and indirectly-framed attacks. **Its false-positive rate on benign prompts is 44%** - the built-in rules are keyword-shaped, so an ordinary prompt about enabling developer mode on a phone, or a colleague named Dan, matches. Treat the built-ins as a signal to tag or warn on; scope a `block`-action policy to rules you have measured against your own traffic. ## Presidio (optional) For NER-based entities, add a **Presidio** sidecar as a tenant Integration: 1. Deploy the Presidio sidecar (see `docker/presidio-sidecar/`). 2. In **Settings → Integrations → Presidio**, set the sidecar URL, timeout, and score threshold. The gateway resolves the sidecar per tenant from your Integration config (never an environment variable), and merges Presidio matches into the sync detector stack on the **buffered outbound request path**. **A configured analyzer that cannot run is never silent.** Three cases count as "cannot run": the URL is refused by the egress check (a private, loopback, link-local or cloud-metadata address, unless you set `allow_private_host` on a self-hosted install); its circuit breaker is open after five consecutive failures; or **the call itself failed on this request** — a timeout or an error, which can happen before the breaker opens. In all three: * **With a PII policy attached** — `503 pii_policy_degraded`. Requests are refused rather than forwarded, for the same reason a recognizer that will not compile refuses: the policy you attached is only partly in force. The in-process tiers do not detect names, addresses or medical record numbers, so losing the analyzer is losing most of what the policy was for. * **With no policy attached** — the request is served, but carries `X-Knox-AI-Pii-Degraded: presidio_url_refused` (or `presidio_circuit_open`, or `presidio_call_failed` for a per-request failure) and writes a `detector_unavailable` row to the [PII events](#pii-events) ledger. Nothing was asked to be enforced, so it is not a refusal — but you find out here, not later. A per-request failure is **retryable**: it says this call did not get NER coverage, not that the analyzer is down. Under an attached policy the `503` is the signal to retry. Having **no** analyzer configured is a different thing and stays silent: nothing was asked for, so nothing is missing. A single mid-scan failure that has not yet tripped the breaker is still absorbed (the sync tiers cover the request). Only the two cases above are surfaced today. ### Where the sidecar may live The analyzer receives **the prompt text being inspected**, so its URL is treated as an egress destination and validated on every call: the hostname is resolved, every returned address is checked against the private/reserved ranges, and the request is sent to the address that was checked (so a name that flips after validation cannot be reached). Non-`http(s)` schemes are refused. * On the **cloud** service the analyzer must be a publicly-routable host. A private-network URL is refused when you save it (`presidio_url_blocked`); an existing one stops being used, and the stack runs on its built-in tiers. * On a **self-hosted** deployment, tick **“Analyzer is on this deployment’s private network”** on the same Integration to allow a sidecar on `10.x`, `192.168.x`, `172.16.x`, an IPv6 ULA, or `127.0.0.1`. It is off by default and ignored on the cloud service. * Link-local addresses (`169.254.0.0/16`, `fe80::/10` — the cloud metadata range) are **never** permitted, with or without that setting. ## PII events Every redaction (and every skipped detection in monitor mode) is written to the `ai_gateway_pii_events` ledger with the entity type, detector, direction (request/response), and action. [Compliance-pack](/ai-gateway/compliance-packs) alert rules can count these — e.g. *"alert when an SSN appears in a response"* — and they feed [evidence exports](/ai-gateway/compliance-packs). ## Batch requests Prompt inspection follows the request shape, not the endpoint. A batch submit is an ordinary POST whose body nests each real request one level down, so the gateway walks into it: ```json theme={"dark"} {"requests":[{"custom_id":"a","params":{"model":"…","messages":[{"role":"user","content":"…"}]}}]} ``` Every sub-request's prompt text is tokenized, firewall-scanned and checked against the agent's model allow/deny lists — the same controls, reading the same fields, as a single request. The tokenizer and the firewall read one shared enumeration (`src/ai-gateway/prompt-shapes.ts`), so they can never disagree about what the prompt is: * Anthropic `system` (string and block-array) and `messages[].content` * OpenAI `input`, `instructions` and legacy `prompt` * Gemini `contents[].parts[].text` and `systemInstruction` * Cohere `message`, `preamble` and `chat_history[].message` * RAG grounding `documents[]` (bare strings, and `text` / `snippet` / `title`) * Tool **descriptions**, on all four declaration shapes — a description is prompt text the model reads verbatim on every turn, and it is where a real example ("look up a patient by SSN, e.g. …") tends to get pasted Tool *schemas* (`parameters`, `input_schema`) are deliberately not rewritten: the provider validates against them, they carry field names rather than values, and corrupting one breaks the tool call without protecting anything. If one sub-request names a model the agent may not use, the **whole batch is refused**: accepting it partially would hand you a batch id that does not describe what you submitted. ### OpenAI's file-based batches OpenAI's Batch API does not carry your prompts. You upload them to the Files API first and submit `{"input_file_id": "file-…"}`, so the gateway never sees the text. If your agent has PII tokenization or a prompt firewall configured, that submit is **refused with 422 `batch_input_not_inspectable`**. The alternative would be to forward it with both controls silently inert — a request that looks protected and is not. Two ways forward: * Use the **Anthropic Message Batches** API, which carries its requests inline and is inspected in full. * Turn the control off for that agent, if the batch genuinely carries no sensitive data — an explicit choice, recorded on the agent. ## Configure a policy PII policies and recognizers are managed on the **PII tab** of the gateway detail page. A policy selects which entity types to detect and the default action for a match. Attaching a policy to an agent selects **what is detected**. It does not by itself decide what happens to a match — that is [`pii_request_mode` and `pii_response_mode`](#the-two-directions) on the agent, which default to `tokenize` and `detokenize`. An agent with no policy still runs the built-in detector tier in both directions. Attaching a policy also makes the gateway fail **closed** on it: if the policy cannot be loaded, does not resolve for your tenant, or can only be partly applied (a recognizer that will not compile, a `presidio_*` rule with no sidecar, more recognizers than the active limit), the request is refused with `503 pii_policy_unavailable` or `pii_policy_degraded` rather than forwarded under a policy that is not fully in force. They are also a first-class part of the API, so the whole thing is scriptable. ## Scripting it end to end A policy is a named bundle of **recognizers** plus a `default_action`. An agent points at one through `pii_redact_policy_id`. Everything below is on `/v1/ai-gateway/*` and needs only a management API key — no browser step. ```bash curl theme={"dark"} # 1. A recognizer for a shape we invented and no built-in tier knows about. # Try the pattern first — this saves nothing. curl -sX POST https://api.knoxcall.com/v1/ai-gateway/pii-recognizers/test \ -H "Authorization: Bearer $KC_TOKEN" -H 'Content-Type: application/json' \ -d '{"pattern":"MRN-[0-9]{7}","text":"chart MRN-1234567 attached"}' # → {"data":{"matched":true,"matches":[{"span":[6,17],"matched":"MRN-1234567", …}]}, …} RECOGNIZER=$(curl -sX POST https://api.knoxcall.com/v1/ai-gateway/pii-recognizers \ -H "Authorization: Bearer $KC_TOKEN" -H 'Content-Type: application/json' \ -d '{"name":"medical_record_number","kind":"regex","pattern":"MRN-[0-9]{7}","action":"redact"}' \ | jq -r .data.id) # 2. A policy bundling it. Every id here must be a recognizer you own. POLICY=$(curl -sX POST https://api.knoxcall.com/v1/ai-gateway/pii-policies \ -H "Authorization: Bearer $KC_TOKEN" -H 'Content-Type: application/json' \ -d "{\"name\":\"HIPAA\",\"recognizer_ids\":[\"$RECOGNIZER\"],\"default_action\":\"redact\"}" \ | jq -r .data.id) # 3. Attach it to an agent at create time — or PATCH an existing one. curl -sX POST "https://api.knoxcall.com/v1/ai-gateway/gateways/$GATEWAY/agents" \ -H "Authorization: Bearer $KC_TOKEN" -H 'Content-Type: application/json' \ -d "{\"name\":\"Clinical assistant\",\"slug\":\"clinical\",\"provider\":\"anthropic\", \"upstream_secret_id\":\"$SECRET\",\"pii_redact_policy_id\":\"$POLICY\"}" ``` ```javascript Node theme={"dark"} import { KnoxCall } from "@knoxcall/knoxcall"; const kc = new KnoxCall({ apiKey: process.env.KNOXCALL_API_KEY }); // Compile-and-scan on the server, with the engine the data plane runs. const preview = await kc.aiGateway.testPiiRecognizer({ pattern: "MRN-[0-9]{7}", text: "chart MRN-1234567 attached", }); console.log(preview.matched, preview.matches[0].matched); // true 'MRN-1234567' const recognizer = await kc.aiGateway.createPiiRecognizer({ name: "medical_record_number", kind: "regex", pattern: "MRN-[0-9]{7}", action: "redact", }); const policy = await kc.aiGateway.createPiiPolicy({ name: "HIPAA", recognizer_ids: [recognizer.id], default_action: "redact", }); const agent = await kc.aiGateway.createAgent(gatewayId, { name: "Clinical assistant", slug: "clinical", provider: "anthropic", upstream_secret_id: secretId, pii_redact_policy_id: policy.id, }); ``` ```python Python theme={"dark"} from knoxcall import KnoxCall kc = KnoxCall(api_key=os.environ["KNOXCALL_API_KEY"]) preview = kc.ai_gateway.test_pii_recognizer( pattern=r"MRN-[0-9]{7}", text="chart MRN-1234567 attached" ) assert preview["matched"] recognizer = kc.ai_gateway.create_pii_recognizer( name="medical_record_number", kind="regex", pattern=r"MRN-[0-9]{7}", action="redact", ) policy = kc.ai_gateway.create_pii_policy( name="HIPAA", recognizer_ids=[recognizer["id"]], default_action="redact", ) agent = kc.ai_gateway.create_agent( gateway_id, name="Clinical assistant", slug="clinical", provider="anthropic", upstream_secret_id=secret_id, pii_redact_policy_id=policy["id"], ) ``` ### Four refusals worth knowing before you script against them **Test the pattern on the server, not locally.** Patterns are compiled with a linear-time engine. Lookahead, lookbehind and backreferences — which every client-side regex engine accepts — are rejected. A local preview would show you matches for a recognizer that can never run, and the recognizer would then be skipped at scan time. A skipped detector fails **open**. **An empty `recognizer_ids` means *every* enabled recognizer**, not none. It is the "use everything I own" setting. Passing `[]` on an update is therefore a widening, not a clearing. **A recognizer id you do not own is a `400 recognizer_not_found`** at write time, on create and on update. The column has no foreign key, and the loader intersects with the recognizer's own tenant — so a stored foreign id would give you a policy that lists three recognizers and runs zero. That is indistinguishable from working redaction right up until the PII reaches the provider, which is why it is refused rather than accepted. **Deletes are refused while something still points at the row.** `DELETE` a policy an agent still references and you get `409 policy_in_use`: the foreign key is `ON DELETE SET NULL`, so the delete would quietly detach every bound agent and turn redaction off for each of them. `DELETE` a recognizer a policy still lists and you get `409 recognizer_in_use` — dropping the id would widen the policy, per the rule above. Detach first, deliberately. # AI Gateway Quickstart Source: https://docs.knoxcall.com/ai-gateway/quickstart Route your first AI call through the gateway with a one-line base-URL swap — Anthropic or OpenAI SDK, a phantom token, and every guardrail on by default. # AI Gateway Quickstart You adopt the AI Gateway by pointing your existing AI SDK at your agent's gateway URL and using a **phantom token** as the API key. No new SDK, no code rewrite. ## 1. Create an agent In the dashboard, go to **AI Gateway → New agent**. Pick your provider — Anthropic, OpenAI, Google Gemini, Cohere, Azure OpenAI or a self-hosted Ollama-compatible endpoint — paste that provider's API key, and press **Create agent**. KnoxCall does the rest in one step: it encrypts the key into a normal KnoxCall secret, creates the upstream route that injects it, creates the gateway if you do not have one yet, creates the agent, and mints your first capability key. Azure OpenAI and self-hosted endpoints also ask for the base URL of your own resource, because that address is specific to you. When the agent is created you get two things: * an **agent URL** — `https://.knoxcall.com/v1/ai/` (sandbox: `https://sandbox-.knoxcall.com/...`) * a **phantom token** — `kp_live_a_…` (shown once at mint time) The final step also shows the environment variables, the curl command and the SDK snippet already filled in with that token, and a **Send test request** button that runs one prompt through the agent you just created. The provider key you paste is envelope-encrypted with your tenant key before it is stored, and no endpoint ever returns it. It is injected at the upstream hop, so clients holding the capability key never see it. Prefer to wire it up piece by piece? **New Gateway** still creates an empty gateway, and the agent form still lets you point at an existing secret instead of pasting a key. The data plane is served on your tenant's **proxy subdomain** (`.knoxcall.com`), not `api.knoxcall.com`. Always use the `agent_url` returned at agent creation as your base URL. ## 2. Swap your base URL ### Anthropic SDK ```python theme={"dark"} from anthropic import Anthropic client = Anthropic( base_url="https://acme.knoxcall.com/v1/ai/support-bot", # your agent URL api_key="kp_live_a_...", # your phantom token ) msg = client.messages.create( model="claude-sonnet-5", max_tokens=1024, messages=[{"role": "user", "content": "Summarize this ticket…"}], ) print(msg.content[0].text) ``` ### OpenAI SDK ```python theme={"dark"} from openai import OpenAI client = OpenAI( base_url="https://acme.knoxcall.com/v1/ai/support-bot", api_key="kp_live_a_...", ) resp = client.chat.completions.create( model="gpt-5", messages=[{"role": "user", "content": "Summarize this ticket…"}], ) print(resp.choices[0].message.content) ``` ### Node / TypeScript The provider's own Node SDK works unchanged — same swap, same two lines: ```typescript theme={"dark"} import Anthropic from '@anthropic-ai/sdk'; const client = new Anthropic({ baseURL: 'https://acme.knoxcall.com/v1/ai/support-bot', // your agent URL apiKey: process.env.KNOXCALL_AI_TOKEN, // your kp_ phantom token }); const msg = await client.messages.create({ model: 'claude-sonnet-5', max_tokens: 1024, messages: [{ role: 'user', content: 'Summarize this ticket…' }], }); console.log(msg.content[0].text); ``` For OpenAI, `import OpenAI from 'openai'` and pass the same `baseURL` / `apiKey`. ### curl ```bash theme={"dark"} curl https://acme.knoxcall.com/v1/ai/support-bot/v1/messages \ -H "Authorization: Bearer kp_live_a_..." \ -H "content-type: application/json" \ -d '{ "model": "claude-sonnet-5", "max_tokens": 1024, "messages": [{"role": "user", "content": "Hello"}] }' ``` ### What comes back The provider's own response shape, unchanged — the gateway does not wrap it: ```json theme={"dark"} { "id": "msg_01XyZ…", "type": "message", "role": "assistant", "model": "claude-sonnet-5", "content": [{ "type": "text", "text": "The customer is reporting…" }], "stop_reason": "end_turn", "usage": { "input_tokens": 27, "output_tokens": 112 } } ``` What the gateway adds is in the **headers**, not the body: ```http theme={"dark"} X-Request-Id: 0d5b2a9e-1f3c-4a7d-8e2b-6c9a1f4d7e35 X-Knox-AI-Budget-Pct: 12.4 ``` `X-Request-Id` is on every response and is the key into your audit trail for that exact call. The rest appear only when there is something to say — see [the header reference](#useful-requestresponse-headers) below. A refusal replaces the body with an [error envelope](/ai-gateway/errors); nothing is ever silently degraded. That's it. The call is now authenticated by the phantom token, screened by the [prompt firewall](/ai-gateway/firewall), [PII-redacted](/ai-gateway/pii-redaction) if a policy applies, counted against the agent's [budget](/ai-gateway/budgets-finops), and recorded with per-call [cost attribution](/ai-gateway/budgets-finops). ## 3. Authentication schemes The phantom token can be sent three ways — use whichever your SDK makes easiest: ```http theme={"dark"} Authorization: Bearer kp_live_a_... x-api-key: kp_live_a_... x-knox-ai-key: kp_live_a_... ``` For [DPoP](/ai-gateway/tokens-and-dpop)-bound tokens, also send a `DPoP` proof header on every request. ## Everything you need is on the agent's Connect panel You do not have to keep the values from the create wizard. Open the agent (**AI Gateway → your gateway → the agent**) and the **Connect** panel at the top of the page has: * the **agent URL**, copyable — the base URL your SDK points at; * the **environment variables** for that agent, already filled in (`ANTHROPIC_BASE_URL` / `OPENAI_BASE_URL` and the key variable your provider's SDK reads), so a working shell is one paste away; * a **curl smoke test** shaped for that agent's provider; * the newly issued **capability key**, the one time it is shown, when you mint from the Capability keys list below it; * a **Playground** button, which runs a prompt through this agent's real data plane and shows what the gateway did to it. The panel derives the provider from the agent's route, so the snippets are for the provider that agent actually calls — not a guess from the model name. A capability key is displayed **once**, at mint time. If you lose it, revoke it and mint another; there is no endpoint that returns an existing token's value. ## 4. Streaming Streaming works unchanged — set `stream: true` (an `Accept: text/event-stream` header alone does not stream). PII redaction runs **inside** the stream, so redactions appear as the tokens arrive rather than after the response completes: ```python theme={"dark"} with client.messages.stream( model="claude-sonnet-5", max_tokens=1024, messages=[{"role": "user", "content": "…"}], ) as stream: for text in stream.text_stream: print(text, end="") ``` ## Try it from the dashboard: the Playground Every agent has a **Playground** (agent page → *Playground*). It sends the prompt you type through that agent's own `/v1/ai/` data plane — the same URL your SDK uses — with a one-shot capability key that expires the moment it is used, and shows what the gateway did to the call beside the reply: * **the redaction diff** — your prompt with every detected value highlighted, next to the exact text the provider received, with the format-preserving surrogates that replaced them; * **the firewall verdict** for the prompt; * **cache outcome, model, latency and cost** for the request. Those numbers are read back from the rows that request wrote (`ai_gateway_usage`, `ai_gateway_firewall_events`) and the PII token map for its conversation — not recomputed for display. Where a row is missing the panel says so rather than showing a reassuring default. The Playground spends the same provider tokens a real call does, and is subject to whatever budget the agent has: a quickstart agent starts with none, so set one on the agent before handing the Playground to a wider team. ## From the command line The KnoxCall CLI carries an `ai` command group over the same `/v1` control plane, so gateways, agents, minting and spend are all reachable without opening the dashboard. It reuses the credential `knoxcall login` writes — no keys in scripts, and the tenant is resolved from the token, so no tenant header. The token acts with your role: `ai mint` (and creating or changing gateways and agents) needs an owner or admin, exactly as in the dashboard, while the read commands work for any member: ```bash theme={"dark"} knoxcall ai gateways # id · slug · name knoxcall ai agents # that gateway's agents knoxcall ai mint --kind agent \ --name "CI pipeline" # shown once knoxcall ai usage --period 30d # cost + tokens by model knoxcall ai usage --period 7d --agent ``` **The `ai` command group ships in the `knoxcall-cli` package, which is not published to a registry yet.** The `knoxcall` binary that comes with the language SDKs today covers `login`, `logout`, `whoami` and `init` — not `ai`. Until `knoxcall-cli` is published, use the `/v1` endpoints directly; the commands above map one-to-one: | Command | Endpoint | | - | - | | `ai gateways` | `GET /v1/ai-gateway/gateways` | | `ai agents ` | `GET /v1/ai-gateway/gateways/{gatewayId}/agents` | | `ai mint ` | `POST /v1/ai-gateway/agents/{agentId}/tokens` | | `ai usage` | `GET /v1/ai-gateway/usage` | ## Useful request/response headers | Header | Direction | Meaning | | - | - | - | | `X-KC-User` | request | the person's directory key for per-user cost attribution — SCIM `externalId` (else `userName`), or a member's KnoxCall email ([details](/ai-gateway/budgets-finops)) | | `X-KC-Conversation-Id` | request | conversation id — scopes the reversible PII token map **and the response cache** across turns | | `X-Request-Id` | response | UUID for this call (appears in audit logs) | | `X-Knox-AI-Budget-Pct` | response | current budget utilization % | | `X-Knox-AI-Tools-Stripped` | response | tools removed by the agent's tool allowlist | | `X-KC-Team` | request | cost-centre override — the UUID of a group your workspace has declared; see [attribution](/ai-gateway/budgets-finops#the-x-kc-team-override) | | `X-Knox-AI-Cache` | response | present on a cache hit (`exact` or `semantic`) | | `X-Knox-AI-Attribution` | response | why an attribution claim was not used: `user_unregistered`, `team_malformed`, `team_unknown` (comma-separated when more than one applies) | | `X-Knox-AI-Output-Warning` | response | the agent's [output schema](/ai-gateway/model-and-output-policy#output-validation) was violated and the action is `warn` | | `X-Knox-AI-Retry` | response | `output_schema` — the answer you are reading is a schema-corrected retry | | `X-Auth-Failure` | response | on a 401, the machine-readable refusal code (e.g. `step_up_required`, `oneshot_already_used`) | ## Response caching Caching is **off by default** and is set per agent with `cache_mode` — in the dashboard on the Agent's **Budgets & cache** tab, or over the `/v1` Management API. An agent's `cache_mode` is `off` unless you set it deliberately. | Mode | Behaviour | | - | - | | `off` (default) | No caching. | | `exact` | A byte-identical request replays the saved response. | | `semantic` | A request that *means the same thing* as a prior one replays it, matched by embedding cosine similarity above `cache_similarity_threshold` (default **0.95**). Falls back to exact matching when it cannot embed. | A cache hit costs **\$0**, is recorded with `cost_source = 'cache_hit'`, does not charge the agent's budget, and sets `X-Knox-AI-Cache: exact` or `semantic`. ### What the hit rate actually is The Agent's **Budgets & cache** tab reports, over the last 30 days: hit rate, exact hits, near (semantic) hits, misses, bypassed requests, stored entries, and — for an agent in `semantic` mode — how many requests approximate matching actually ran for, its own hit rate, and how many degraded, **with the reason**. Two things about that report are worth knowing before you read a number off it. **Bypassed is not a miss.** A request that streamed, that hit a response-direction guardrail hook, that ran on an agent with a customer-managed key, or that the gateway could not attribute to a token, was never eligible for the cache. Those are counted beside the rate and never inside it. An agent whose traffic is all `stream: true` will show a large bypass count and no hit rate, which is the truthful answer rather than 0%. **Before 2026-09-08 this number was wrong** — for an agent in `exact` mode it read 100% from its first hit onward, because misses were never recorded. If you looked at it before that date, look again. **The number we have, and what it is worth.** On our reference fixture (`support-assistant-v1`: 7 conversations, 30 requests, a mix of verbatim repeats and paraphrases) at the default `0.95` threshold, **13.3% of requests were served from cache** — three exact, one approximate — with no false matches and no cross-caller hits. At `0.90` it is 30%; the exact-cache floor, which needs no embedding model at all, is 10%. That is a **fixture** measurement produced with a substitute embedder, because our test suite may never call a provider. It is not a forecast of your traffic, and we will not dress it up as one: how much a response cache saves depends almost entirely on how repetitive your prompts are. The number that matters is the one on your own Agent's cache panel. ### Isolation — what a hit can and cannot cross Every cache entry is keyed on **the phantom token that produced it and the conversation id**, in addition to the agent and the request body. That means: * a cached response **never crosses tenants**; * it **never crosses tokens**, even inside one tenant — two users of the same agent do not serve each other's answers, whatever conversation ids they send; * it **never crosses conversations** for one token; * a request the gateway cannot attribute to a token neither reads nor writes the cache at all. The token is the boundary that carries the weight, because it is the identity KnoxCall verifies. `X-KC-Conversation-Id` is a client header — still worth making unguessable and unique per end-user conversation, since it partitions a single token's own cache, but it is no longer the only thing standing between two callers. ### Semantic matching — the requirements Semantic matching runs only when **all four** of these hold (`cache/index.ts`, `semanticEnabled`). Each one is a boundary, not a limitation to work around: 1. `cache_mode` is `semantic` **and** `cache_embedding_model` is set. 2. The request carries an **`X-KC-Conversation-Id`**. Approximate matching needs an isolation boundary to be confined to; a stateless request has none, so it is exact-only. 3. The agent is **not** using reversible PII tokenization — that is, `pii_request_mode` is `off` **and** `pii_response_mode` is `redact`. Either half on its own is enough to keep semantic matching off. Conversation scoping already makes it safe, but approximate matching against a per-conversation token map is held apart until a dedicated review clears it. Tokenizing agents still get exact caching. 4. The upstream exposes an **OpenAI-compatible `/v1/embeddings`** on the same origin as the chat endpoint. Anthropic has no embeddings API, so an Anthropic-backed agent is exact-only. When embedding is unavailable the request degrades cleanly to an exact-cache miss — never an error on the data plane. Caching applies to **buffered** responses only. A streaming request is never served from cache and never stored, so an agent whose traffic is all `stream: true` will see no hits. ## Next steps Why AI features use your own Anthropic key, and how to add it. Mint scoped, sender-constrained tokens for agents and CI. Pin which models an agent may call, and the shape of what comes back. The ceilings the data plane enforces, and what we do and don't promise. # Routing, Retries & Fail-over Source: https://docs.knoxcall.com/ai-gateway/routing-and-failover Retry on 429 with Retry-After, exponential backoff with jitter, weighted load-balancing across providers, and gateway-wide model aliases — configured per agent, enforced in the data plane. # Routing, Retries & Fail-over Your application points at one agent URL. What happens behind it — how many providers, which one served this call, what happened when the first one said "slow down" — is the agent's configuration, not your code. ## The default Every agent starts with `routing_policy: {}`, and that means: * **one attempt** per route, no retries; * **fail over on 5xx** to `fallback_route_ids`, in order; * everything else — 429, 4xx, 2xx — returned to you unchanged. That is deliberate, and it is what an agent does until you ask for something else. In particular a **429 comes straight back to you**: rate limits are information your client may want to act on, and silently absorbing them into a second provider's bill is not a decision the gateway makes for you. ## Retrying a rate limit The one you almost certainly want: ```json theme={"dark"} { "routing_policy": { "retry_on": ["429", "5xx"], "max_attempts": 3, "backoff_ms": 250, "backoff_multiplier": 2, "max_backoff_ms": 5000 } } ``` Now a 429 is retried against the same route — honouring the provider's own `Retry-After` header — and if the route is still rate-limited after `max_attempts`, the request moves to the next candidate. `Retry-After` is honoured in **one direction only**: it can extend a wait, never shorten one. A provider saying "retry after 1s" while the gateway is already backing off 3.2s does not get hammered at 1s. Both [RFC 9110 forms](https://www.rfc-editor.org/rfc/rfc9110#field.retry-after) are understood — delta-seconds and an HTTP-date. **Jitter is on by default.** Each delay is spread across 50–100% of its computed value, so a fleet of workers that all hit the same rate limit at the same moment does not retry in lockstep and re-trip it. Set `"jitter": false` if you need a deterministic schedule for a test. ### Timeouts `"retry_on": ["timeout"]` covers a transport failure — connection reset, DNS failure, the upstream hanging up. It is separate from `5xx` because they call for different appetites: a 502 usually means "this provider is having a bad minute", a connection reset often means "that one packet was unlucky". ## Weighted load-balancing Two keys on the same provider, or two providers entirely, split by weight: ```json theme={"dark"} { "routing_policy": { "route_weights": { "": 3, "": 1 } } } ``` 75% of requests are served by the first route, 25% by the second — and the one not chosen is still in the fail-over order behind it, so a weight spreads load without giving up redundancy. Three rules worth knowing: * **A route you do not name keeps weight 1.** Adding a weight for one route does not silently drain the others. * **Weight 0 drains a route completely** — no ordinary traffic and no fail-over traffic. That is what "park this provider" means; if you wanted "try it last", give it weight 1 against a much larger one. * **Your token's scope still wins.** The capability scope on the phantom token is checked against *every* candidate before it is dialled, so a weight can never reach a route the token was not granted, and neither can a fail-over. Same for Live/Test: a `kp_test_` token resolves only sandbox routes at every hop. ## Model aliases Set once on the gateway; every agent under it honours them: ```bash theme={"dark"} curl -X PATCH https://api.knoxcall.com/v1/ai-gateway/gateways/$GATEWAY_ID \ -H "Authorization: Bearer $KNOXCALL_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"model_aliases": {"fast": "claude-haiku-4-5", "flagship": "claude-opus-5"}}' ``` Your services then ask for `"model": "fast"` and you change what that means in one place. Chains work (`cheap` → `fast` → a real id); a cycle resolves back to the model you asked for rather than looping. Aliases resolve **before** the agent's model policy, which is the important part: an alias target still has to pass that agent's allowlist and denylist. If the resolution ran the other way round, `{"safe-model": "the-one-you-banned"}` would be a one-line way around your own denylist. ## What it recorded Every usage row now carries what actually happened: | Column | Meaning | | - | - | | `served_route_id` | The route that produced the response — not necessarily the agent's primary, once weights or fail-over are in play. | | `attempts` | Total upstream dispatches for the request, including the first. `1` is the ordinary case. | Both are `NULL` for a cache hit, because nothing was dispatched. `attempts > 1` is the signal worth alerting on: it means a provider is making you pay for the same answer twice. ## The limits, and why they exist Every field is clamped server-side: | Field | Ceiling | | - | - | | `max_attempts` | 5 | | any single delay | 30s | | a honoured `Retry-After` | 60s | | total sleep for one request | 60s | | any `route_weights` value | 1000 | These are not tuning suggestions. A request waiting to retry is holding an API worker, so an agent configured with `max_attempts: 10000` and `backoff_ms: 600000` would be a self-inflicted outage — and a hostile upstream answering `429` with `Retry-After: 86400` would be someone else's. The ceilings are what make "configure your own retries" safe to offer at all. `max_attempts` is also forced back to 1 whenever `retry_on` is empty, so a stored policy never claims a retry that cannot happen. An unrecognised key inside `routing_policy` is rejected with a 400 naming the field rather than stored and ignored — a `retryOn` typo that reads as configured and does nothing is worse than an error. # Streaming Guarantees Source: https://docs.knoxcall.com/ai-gateway/streaming-guarantees Three streaming PII-redaction modes — holdback (mid-stream), buffer (full-buffer), monitor (passthrough) — with a published latency benchmark whose relative guarantees are enforced by CI. # Streaming Guarantees Most gateways can only inspect a streamed AI response *after* it finishes, or *log* what they saw. KnoxCall redacts **inside the live SSE stream**. This page states the per-mode guarantee and publishes the latency cost — a number competitors who buffer or don't enforce mid-stream can't put on the table. ## What in a stream is scanned Redaction applies to the model's **text output**, in every content block a response carries — not just the first: | Provider | Scanned | | - | - | | Anthropic | `text_delta` and `thinking_delta` (extended-thinking reasoning), per content-block index | | OpenAI / Azure OpenAI / Ollama | `delta.content` and `delta.refusal`, for **every** choice when `n > 1` | | Gemini | every candidate, and every text part within a candidate | | Cohere | no SSE strategy — Cohere agents are called buffered, so the response is scanned as a whole | Each block gets its own hold-back window, so text from one completion is never spliced into another's. **Not scanned: streamed tool-call arguments** — `delta.tool_calls[].function.arguments` (OpenAI) and `input_json_delta` (Anthropic). They are JSON fragments arriving a few characters at a time, and splicing a replacement mid-fragment produces a document your client cannot parse, so redacting them needs a different mechanism than the hold-back window. If your agent passes sensitive values into tool arguments, use the [MCP gateway](/ai-gateway/mcp-gateway) — which *does* redact tool arguments recursively — or turn streaming off for that agent so the whole response is scanned as one document. **Extended thinking and `signature`.** Redacting reasoning text means the `signature` Anthropic attaches to a thinking block no longer matches its contents, so replaying that block verbatim on a later turn will be rejected. This is not new behaviour on the streaming path — the buffered path has always scanned the whole response body — and the alternative is streaming your users' data in the clear. Re-prompt rather than replaying thinking blocks through the gateway. ## The three modes Each agent picks a `pii_streaming_mode` (default `holdback`). | Mode | Client-visible guarantee | First-byte latency | Use when | | - | - | - | - | | **holdback** (default) | PII is redacted **inside the stream** using a sliding hold-back tail (`pii_streaming_holdback_chars`, default 96) so an entity that straddles two SSE frames is still caught before any part of it is emitted — including its first half. | Near-instant — tokens flow as they arrive. | The default. Real-time UX with real protection. | | **buffer** | The **entire** response is accumulated, redacted once, then emitted. Zero chance a cross-frame entity slips through, at the cost of first-byte latency (the client waits for the whole message). | Deferred — first byte after the full response arrives. | Maximum assurance; non-interactive/batch calls. | | **monitor** | The stream passes through **unredacted** for lowest latency, but every entity is still detected and written to the [PII event ledger](/ai-gateway/pii-redaction) for alerting and evidence. | Passthrough — no added first-byte latency. | You need visibility, not enforcement (e.g. observing before you turn redaction on). | All three record detections; only `holdback` and `buffer` alter the bytes the client sees. ## Why holdback is the hard one (and the differentiator) A sensitive token can arrive split across SSE frames — `"...SSN 123-"` in one delta, `"45-6789..."` in the next. A naïve mid-stream redactor emits the first half before it can tell it's PII. KnoxCall's hold-back FSM keeps the last N characters buffered until enough context has arrived to decide, then splices any redaction back into the SSE frame and re-serialises it — so the client never sees a partial entity, and framing is never corrupted. Bedrock punts (`stream:false` required); Cloudflare buffers; Portkey's streaming guardrails are log-only. KnoxCall enforces. ## Published benchmark `scripts/bench-ai-gateway-streaming.ts` drives a representative Anthropic response (120 text deltas seeded with email/SSN/credit-card entities) through each mode and a raw passthrough baseline, and records **p50/p95 TTFB** (time-to-first-emitted-byte) and **total drain time**. The snapshot lives at `scripts/bench-ai-gateway-streaming.results.json`. This is a CPU-bound micro-benchmark of the redaction Transform itself (no network) — it isolates the overhead the gateway *adds* on top of the hundreds of milliseconds an LLM already takes. **Representative** numbers from one run on a developer machine: | Mode | TTFB p50 | Total p50 | TTFB overhead vs passthrough | | - | - | - | - | | passthrough | \~0.01 ms | \~0.03 ms | — | | **holdback** | **\~0.03 ms** | \~0.9 ms | **\~0.02 ms** | | buffer | \~0.24 ms | \~0.37 ms | \~0.23 ms | | monitor | \~0.01 ms | \~0.32 ms | \~0.00 ms | The headline: **mid-stream redaction (holdback) reaches first byte in a small fraction of the time full-buffering does**, and the absolute cost is far below the hundreds of milliseconds an LLM already takes. You get real, enforced, mid-stream PII protection essentially for free on the latency budget. ## What CI actually enforces Absolute milliseconds depend on the machine, so we do not assert them — a guard that fails because a runner was busy is a guard that gets deleted. What **is** enforced, on every commit, by `tests/ai-gateway-streaming-latency-invariants.test.ts` under plain `npm test`, driving the same harness this benchmark uses: * **holdback emits to the client before the upstream stream has ended** — the "redacts inside the live stream" claim reduced to an ordering of two events, with `buffer` as the negative control. No timing involved, so it cannot flake and cannot be satisfied by a fast full-buffer. * **holdback's TTFB is a small fraction of buffer-mode's**, and its overhead over raw passthrough stays under a generous ceiling. * **monitor is indistinguishable from passthrough** on first-byte latency and is byte-identical on the wire. * **holdback and buffer actually redact** the planted email, SSN and the credit-card number that is deliberately *split across four SSE frames* — checked on the reassembled text the end user reads, not on the wire bytes. Without this, a redactor that did nothing would post the best latency numbers in the file. * **the committed snapshot satisfies the same relative invariants**, so `--write` cannot commit numbers that disagree with the code. Re-generate the snapshot with `npx tsx scripts/bench-ai-gateway-streaming.ts --write`. Absolute numbers will differ on your hardware; the orderings above will not. ## The hold-back window `pii_streaming_holdback_chars` (default 96) is the sliding tail. It must be at least as long as the entity you expect, or that entity can never sit inside the window at once. Values below the default are **raised to 96 automatically** rather than honoured: a shorter window silently stops detecting anything longer than itself while still reporting redaction as enabled, and the window costs nothing at first byte (see the benchmark above) — so there is no trade to make. Raise it (256 is a reasonable setting for name/address-style entities) rather than lowering it. Entities **longer than the window** are handled separately. Four detector patterns are open-ended — JWTs and the Anthropic/OpenAI, Stripe and GitHub key shapes — and none of them matches until the whole value has arrived, so the window alone cannot protect them: a real JWT is 200–800 characters. The FSM therefore also recognises a *possible prefix* of one of these anchored at the buffer's end and holds from there, releasing when the value completes and is redacted. Holding is bounded (a few KB per stream); past that bound the gateway redacts what it has rather than hold more, so the identifying head of a credential is never emitted in the clear. ## Configure the mode Set it on the agent — via the **PII tab** on the gateway detail page, or over the API: ```bash theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) curl -X PATCH https://api.knoxcall.com/v1/ai-gateway/agents/ \ -H "Authorization: Bearer $TOKEN" \ -H "content-type: application/json" \ -d '{"pii_streaming_mode": "buffer"}' ``` # Tokens & DPoP Source: https://docs.knoxcall.com/ai-gateway/tokens-and-dpop Phantom capability tokens: short-lived, agent-scoped, revocable credentials — with token kinds, IP/CIDR scoping, DPoP sender-constraint, and OIDC workload federation for CI. # Tokens & DPoP Your application authenticates to the gateway with a **phantom token** — a capability token minted for one agent. It is short-lived, scoped, and revocable, and it stands in for the real provider key (which never leaves the server). ## Token shape ```text theme={"dark"} kp___<32hex>_ ``` * `kp_` — the phantom-token family prefix. It is **disjoint** from every other KnoxCall credential: `kc_` (OAuth 2.1 access), `kr_` (OAuth 2.1 refresh), `tk_` (tenant API key) and `AKE` (enterprise access key). See [the 2026-09-01 changelog entry](/ai-gateway/changelog#phantom-tokens-move-to-their-own-kp_-prefix) — this prefix changed, with no compatibility window. * `env` — `live` or `test`, e.g. `kp_live_a_…` and `kp_test_a_…`. * `kind` — the capability class (below). * The trailing segment is a CRC over everything before it, so a mistyped token is rejected without a database lookup. * The token is validated by a capability HMAC bound to your tenant master key, so a token minted for one tenant can't be replayed against another. **The environment segment is enforced, not cosmetic.** A `kp_test_` token resolves only sandbox routes and a `kp_live_` token only Live ones — the segment is part of the route lookup on the primary route *and* on every failover route, so a 5xx cannot become a way for a Test token to reach a Live upstream. A request whose environment cannot be proven is refused rather than assumed to be Live. ## Token kinds | Kind | Prefix | Use | | - | - | - | | **agent** | `kp_live_a_…` | Full agent access — the normal application/agent credential. | | **read** | `kp_live_r_…` | Read-oriented calls. | | **tool** | `kp_live_t_…` | Tool-bound access, intended to be scoped to an allowlisted tool set. | | **oneshot** | `kp_live_o_…` | Single-use — atomically consumed on first use (ideal for a playground or a hand-off). | ### One-shot tokens and rejected requests A one-shot token is consumed at the END of authentication — after the token hash, expiry, capability HMAC, IP/CIDR window and DPoP proof have all passed, so a request rejected by any of those does **not** burn it. It is consumed **before** any gateway *policy* runs. A one-shot call that is then refused by the model policy, the tool allowlist, the prompt firewall, the budget or the per-call token cap has still spent the token: you get the 4xx explaining the refusal, and retrying the same token returns `401 oneshot_already_used` instead of the original reason. That ordering is deliberate. If the consume waited until after policy, two concurrent replays of one token would both pass authentication, both run the pipeline and both reach the provider — spending real money twice — and the loser would only be told afterwards. A token burned by its own rejected request costs one re-mint; a token replayable during policy evaluation is not single-use at all. **Practical rule:** mint a one-shot per *attempt*, not per *intent*. If your client retries, mint again. ## Scoping A token carries a **capability scope** — a small JSON object, HMAC-signed with your tenant master key, so it cannot be edited without the key. Every key below is enforced; the two marked *at authentication* are checked by the auth middleware, the rest need the resolved request (its model, tools and route) and so are enforced in the data plane, before the upstream is dialled. | Key | Enforced | Refusal | | - | - | - | | `ip_cidrs` | at authentication | `403 ip_cidr_mismatch` | | `valid_window` (`days`, `hours`) | at authentication | `403 outside_valid_window` | | `providers` | per request | `403 provider_not_in_token_scope` | | `models` | per request | `403 model_not_in_token_scope` | | `tools` | per request | `403 tool_not_in_token_scope` | | `egress_routes` | per request, **including every failover hop** | `403 route_not_in_token_scope` | | `max_cost_per_day_usd`, `max_tokens_per_day` | per request | `429 token_scope_limit_exceeded` — see [Budgets](/ai-gateway/budgets-finops#caps-carried-by-the-token-itself) | Expiry is separate from the scope: every token can carry a TTL and an expired token is rejected at the edge. A **malformed** restriction is a refusal, never a pass. `{"models": "claude-haiku-4"}` (a string where a list is required) and `{"models": []}` (a list that restricts to nothing) both refuse the request with `403 malformed_token_scope`, because treating either as "unrestricted" would silently invert the most restrictive value into the most permissive one. **There is no mint API that sets a scope yet.** `POST /v1/ai-gateway/agents/{agentId}/tokens` and its admin twin both stamp an **empty** scope deliberately: neither offers a parameter to set one, and an earlier revision guessed a `providers` restriction from the model name — which mislabelled every Gemini, Mistral and Cohere agent and would have refused the token's own traffic. An empty scope means "no token-level restriction", which is the truth. The one path that produces a scoped token today is an **OIDC binding**, where you author the scope on the binding. A binding whose scope narrows nothing is now refused at write time, because an unnarrowed scope mints an unrestricted token. An explicit `scope` parameter on mint is planned and not shipped; it needs the Postman and five-SDK fan-out that every `/v1` change requires. Revoke a token at any time from the **Tokens tab** on the gateway detail page, or with `DELETE /v1/ai-gateway/agents/{agentId}/tokens/{tokenId}`; revocation takes effect immediately. ### Gateway-level (agentless) tokens Not every phantom token belongs to an agent. A token can be minted at the **gateway** level — it has no `agent_id` — which is how a credential is issued for a whole gateway rather than one agent's traffic. Because such a token is invisible to every agent-scoped route, `/v1` carries two endpoints that address the gateway instead: * `GET /v1/ai-gateway/gateways/{gatewayId}/tokens` — lists the gateway's tokens, agent-scoped **and** agentless. * `DELETE /v1/ai-gateway/gateways/{gatewayId}/tokens/{tokenId}` — revokes any of them, including an agentless one that no agent endpoint can reach. There is no `POST` here: nothing mints a gateway-level token on request. The one thing that produces one is the [OIDC exchange](#oidc-workload-federation-ci-without-static-secrets) when the request carries an RFC 8707 `resource` naming an MCP server — the resulting token is kind `tool`, confined to exactly that one MCP resource by its capability HMAC, and **refused outright on `/v1/ai`**, which requires an agent it does not have. If you find an agentless token you cannot account for, this is the endpoint that revokes it. ## DPoP (sender-constrained tokens) Set `dpop_required: true` on a token to make it **sender-constrained** per [RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449). The client generates a key pair and sends a `DPoP` proof JWT on every request; the gateway binds the token to that key's thumbprint (`jkt`) and rejects the token if presented without a matching proof. This means a leaked token is useless without the corresponding private key — the single most effective mitigation for the "unscoped env-var key" problem that dominates agent deployments. DPoP nonces are tracked in a durable, cross-worker store so replayed proofs are rejected even behind multiple workers. ```http theme={"dark"} POST /v1/ai/support-bot/v1/messages Authorization: Bearer kp_live_a_... DPoP: eyJ0eXAiOiJkcG9wK2p3dC... ``` ## OIDC workload federation (CI without static secrets) CI systems and workloads can exchange a short-lived OIDC **id\_token** (e.g. a GitHub Actions token) for a scoped phantom token via [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchange, so no long-lived secret is stored in the pipeline: ```http theme={"dark"} POST /v1/oauth/token Content-Type: application/x-www-form-urlencoded grant_type=urn:ietf:params:oauth:grant-type:token-exchange &subject_token= &subject_token_type=urn:ietf:params:oauth:token-type:id_token ``` An **OIDC binding** (configured on the agent's Federation section) pins the trusted issuer, audience, and required attribute conditions (for example, a specific repository), and mints a token only when the presented id\_token matches. Subject tokens are single-use — a replayed id\_token is rejected. ## Impossible-travel detection and step-up Set a gateway's `geo_velocity_action` to catch a token used from two places too far apart to be the same holder (a leaked credential in use elsewhere): | Action | What happens | | - | - | | **off** (default) | No geo check. The lookup is never made. | | **warn** | Classify the request's location against the token's last-used location and emit an alertable `ai_gateway.geo_velocity_anomaly` audit event. Alert-only — the token keeps working. | | **block** | Everything `warn` does, **plus**: when the travel is classified anomalous or impossible, flag the token for **step-up**. Its NEXT use is refused. | ### What `block` actually does `block` never revokes a token and never fails the request that triggered it. The geo lookup is an external call made off the hot path, so the *current* request is already on its way upstream by the time the classifier has an answer. What `block` does is set `step_up_required` on the token row, and the **next** presentation of that token is refused at authentication: ```http theme={"dark"} HTTP/1.1 401 Unauthorized X-Auth-Failure: step_up_required { "error": "step_up_required", "error_description": "Re-authenticate to continue — an unusual location change was detected on this token.", "code": "step_up_required" } ``` Match on the `code` field (or the `X-Auth-Failure` header), not the message. `error` carries the same value — see [Errors](/ai-gateway/errors). Before 2026-09-08 this refusal answered `{error: "Unauthorized", code, reason}`; `code` is unchanged, and `reason` is now `error_description`. The refusal happens before the agent is loaded, so no upstream call is made and nothing is charged. This is the safe form of "block", and the reason it is not revoke: a false-positive `impossible` classification — a VPN hop, a mobile carrier's NAT, a stale geo-IP row — would otherwise lock a legitimate user out of a credential they cannot get back. Forcing re-authentication is something the real holder can do and a thief holding only the bearer token cannot. ### Recovering from a step-up Two ways back: 1. **Mint a fresh token** for the agent and reconfigure the consumer. This is the correct response when you cannot rule out that the token leaked. 2. **Clear the flag**, when you have confirmed a false positive (a shared machine key used across regions, a CI runner that moved provider): ```http theme={"dark"} POST /admin/ai-gateway/{gatewayId}/tokens/{tokenId}/clear-step-up X-Tenant-ID: ``` Owner/admin only, tenant-scoped, and it only affects a token that is currently flagged — a call for any other token returns 404. The token resumes working immediately with its original scope and expiry. Clearing a step-up is available on the tenant admin API only. There is no `/v1` equivalent and no button in the admin UI yet, so today this is an API call or a re-mint. Prefer the re-mint unless you are confident the flag was spurious — clearing it puts a token back in service that the gateway told you it could not account for. ### Operational notes * The check **skips the geo lookup entirely when the source IP is unchanged**, which short-circuits the overwhelming majority of requests, and the per-gateway action is cached for 30s. * Every stage fails **open** on error: a geo provider timeout, an unresolvable IP or a failed write costs one comparison, never access. A gateway whose action cannot be read is treated as `off`. * The first request from a token has no prior sample to compare against, so a token is never flagged on its first use. * Because the geo lookup is an external call, enable it deliberately (and pair it with a local geo database at high request volumes). * `warn` and `block` both emit `ai_gateway.geo_velocity_anomaly`, with severity scaled by how implausible the travel is. Watch it with an [alert rule](/monitoring/alerts) on that audit action. **Phantom tokens do not refresh, and there is no refresh-token rotation.** An earlier revision of this page said a rotator and an `ai_gateway.refresh_theft` event existed in the codebase but were unserved. Both were **deleted** — the table they read was never written by anything, so a reviewer grepping for the detector would have found a wired dispatcher and concluded the detection existed. Reporting a signal that cannot fire is worse than reporting none. A phantom token expires and you mint another. Mint with a short TTL (a token with no explicit expiry now defaults to **30 days**, clamped between 60 seconds and 90 days), and for CI use the [OIDC exchange](#oidc-workload-federation-ci-without-static-secrets) — which mints a fresh short-lived token per run and refuses a replayed subject token — rather than storing one. ## Mint tokens Mint from the **Tokens tab** on the gateway detail page, or programmatically via the control-plane mint endpoint. The plaintext token is shown **once** at mint time — store it securely. # Get Account Info Source: https://docs.knoxcall.com/api-reference/account/get Returns your tenant's account details, including subscription plan and billing period ## Get Account Info ```http theme={"dark"} GET /v1/account ``` Returns your tenant's account details, including subscription plan and billing period. ### Response ```json theme={"dark"} { "data": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "slug": "acme-corp", "name": "Acme Corporation", "region": "us-east", "subscription_plan": "pro", "subscription_status": "active", "trial_start_at": "2026-01-01T00:00:00.000Z", "trial_end_at": "2026-01-14T23:59:59.000Z", "subscription_current_period_start": "2026-03-01T00:00:00.000Z", "subscription_current_period_end": "2026-03-31T23:59:59.000Z", "subscription_cancel_at": null, "created_at": "2026-01-01T00:00:00.000Z" }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ```bash cURL theme={"dark"} # $TOKEN is a minted OAuth access token — see /api-reference/authentication curl https://api.knoxcall.com/v1/account \ -H "Authorization: Bearer $TOKEN" ``` ```python Python theme={"dark"} import os import requests resp = requests.get( "https://api.knoxcall.com/v1/account", headers={"Authorization": f"Bearer {os.environ['KNOXCALL_ACCESS_TOKEN']}"} ) account = resp.json()["data"] print(f"Plan: {account['subscription_plan']}, Status: {account['subscription_status']}") ``` ```javascript Node.js theme={"dark"} const resp = await fetch("https://api.knoxcall.com/v1/account", { headers: { Authorization: `Bearer ${process.env.KNOXCALL_ACCESS_TOKEN}` } }); const { data: account } = await resp.json(); console.log(`Plan: ${account.subscription_plan}, Status: ${account.subscription_status}`); ``` ### Errors | Status | Type | Description | | - | - | - | | 404 | `not_found` | Account not found | # Get Current Usage Source: https://docs.knoxcall.com/api-reference/account/usage Returns your current resource usage and plan limits for the current billing period ## Get Current Usage ```http theme={"dark"} GET /v1/account/usage ``` Returns your current resource usage and plan limits for the current billing period. Use this to monitor how close you are to hitting plan limits before creating new resources. ### Response ```json theme={"dark"} { "data": { "billing_period": { "year": 2026, "month": 3, "start": "2026-03-01T00:00:00.000Z", "end": "2026-03-31T23:59:59.000Z" }, "api_calls": { "used": 12847, "limit": 100000, "percentage": 12.85 }, "resources": { "routes": { "used": 8, "limit": 50 }, "secrets": { "used": 12, "limit": 100 }, "clients": { "used": 5, "limit": 25 }, "environments": { "used": 3, "limit": null } }, "plan": "pro", "status": "active" }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` A `limit` of `null` means the resource is unlimited on your current plan. ```bash cURL theme={"dark"} # $TOKEN is a minted OAuth access token — see /api-reference/authentication curl https://api.knoxcall.com/v1/account/usage \ -H "Authorization: Bearer $TOKEN" ``` ```python Python theme={"dark"} import os import requests resp = requests.get( "https://api.knoxcall.com/v1/account/usage", headers={"Authorization": f"Bearer {os.environ['KNOXCALL_ACCESS_TOKEN']}"} ) usage = resp.json()["data"] print(f"API calls: {usage['api_calls']['used']}/{usage['api_calls']['limit']}") ``` ```javascript Node.js theme={"dark"} const resp = await fetch("https://api.knoxcall.com/v1/account/usage", { headers: { Authorization: `Bearer ${process.env.KNOXCALL_ACCESS_TOKEN}` } }); const { data: usage } = await resp.json(); console.log(`API calls: ${usage.api_calls.used}/${usage.api_calls.limit}`); ``` # Agents (v1) Source: https://docs.knoxcall.com/api-reference/ai-gateway/agents List, create, read, update, and archive AI Gateway agents over the public /v1 Management API. # Agents An **agent** belongs to a gateway and defines how one class of AI traffic is proxied: the upstream route, default model and model policy, budgets, streaming, and firewall / PII policies. Once created, an agent is served on the data plane at `/v1/ai/{slug}` — see [Execute AI Request](/api-reference/ai-gateway/execute) to send it traffic. These endpoints are the public `/v1` control-plane equivalent of the dashboard agent pages. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the response envelope, pagination, and error types — the examples below authenticate with `$TOKEN`, a short-lived OAuth 2.1 access token minted there. `/v1` never exposes KnoxCall-managed system agents (for example the Workflows system agent). Reads, updates, token operations, and deletes against a system agent return `404 not_found`. ## The agent object An agent is returned with its full configuration. The most commonly used fields: | Field | Type | Description | | - | - | - | | `id` | string (uuid) | Agent identifier. | | `gateway_id` | string (uuid) | Parent gateway. | | `name` | string | Display name. | | `slug` | string | URL-safe handle, unique per tenant. The agent is served at `/v1/ai/{slug}`. | | `description` | string \| null | Free-text description. | | `primary_route_id` | string (uuid) \| null | Upstream route the agent proxies to. | | `default_model` | string \| null | Default model when the request does not specify one. Drives the phantom-token scope. | | `model_allowlist` | string\[] | Allowed models (empty = no allowlist restriction). | | `model_denylist` | string\[] | Denied models. | | `budget_daily_usd` | string \| null | Daily spend cap in USD (numeric, returned as a string). | | `budget_monthly_usd` | string \| null | Monthly spend cap in USD. | | `streaming_enabled` | boolean | Whether SSE streaming is allowed. Defaults to `true`. | | `firewall_policy_id` | string (uuid) \| null | Attached prompt-injection firewall policy. | | `pii_redact_policy_id` | string (uuid) \| null | Attached PII redaction policy. | | `tool_allowlist` | string\[] | Tool names permitted through the agent. | | `status` | string | One of `active`, `paused`, `archived`. | | `created_at` / `updated_at` | string (ISO 8601) | Timestamps. | The object also includes the remaining agent settings (`fallback_route_ids`, `model_rewrite`, `budget_overage_action`, `pii_detokenize_response`, `pii_streaming_holdback_chars`, `cache_mode`, `output_schema`, `output_validation_action`, and others) at their configured or default values. ## List agents ```http theme={"dark"} GET /v1/ai-gateway/gateways/{gatewayId}/agents ``` Returns the agents in a gateway, paginated (`page`, `per_page`). Requires the `read` capability. Returns `404 not_found` if the gateway does not belong to your tenant. ```bash cURL theme={"dark"} curl "https://api.knoxcall.com/v1/ai-gateway/gateways/3f2a1c4e-8b9d-4e7a-9f1c-2d6e5a8b3c10/agents" \ -H "Authorization: Bearer $TOKEN" ``` ```python Python theme={"dark"} import os import requests TOKEN = os.environ["KNOXCALL_ACCESS_TOKEN"] # OAuth access token — see Authentication gw = "3f2a1c4e-8b9d-4e7a-9f1c-2d6e5a8b3c10" resp = requests.get( f"https://api.knoxcall.com/v1/ai-gateway/gateways/{gw}/agents", headers={"Authorization": f"Bearer {TOKEN}"}, ) agents = resp.json()["data"] ``` The response is a paginated list of agent objects wrapped in `{ data, meta }`. ## Create an agent ```http theme={"dark"} POST /v1/ai-gateway/gateways/{gatewayId}/agents ``` Requires the `write` capability. **Request body** | Field | Type | Description | | - | - | - | | `name` | string | **Required.** Display name. | | `slug` | string | **Required.** 2–64 lowercase alphanumerics/hyphens; unique per tenant. Becomes the data-plane path `/v1/ai/{slug}`. | | `description` | string | Optional. | | `primary_route_id` | string (uuid) | Optional. Upstream route to proxy to. Must belong to your tenant. | | `default_model` | string | Optional default model. | | `model_allowlist` | string\[] | Optional allowed-model list. | | `model_denylist` | string\[] | Optional denied-model list. | | `budget_daily_usd` | number | Optional daily spend cap. | | `budget_monthly_usd` | number | Optional monthly spend cap. | | `streaming_enabled` | boolean | Optional. Defaults to `true`. | | `firewall_policy_id` | string (uuid) | Optional firewall policy. Must belong to your tenant. | | `pii_redact_policy_id` | string (uuid) | Optional PII redaction policy. Must belong to your tenant. | `primary_route_id`, `firewall_policy_id`, and `pii_redact_policy_id` must reference resources owned by your tenant. A reference to another tenant's resource (or a malformed id) returns `400 invalid_reference`. ```bash cURL theme={"dark"} curl -X POST https://api.knoxcall.com/v1/ai-gateway/gateways/3f2a1c4e-8b9d-4e7a-9f1c-2d6e5a8b3c10/agents \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Support Bot", "slug": "support-bot", "description": "Handles tier-1 support questions", "default_model": "claude-sonnet-4-6", "budget_daily_usd": 10.00, "budget_monthly_usd": 200.00, "streaming_enabled": true }' ``` ```python Python theme={"dark"} import os import requests TOKEN = os.environ["KNOXCALL_ACCESS_TOKEN"] # OAuth access token — see Authentication gw = "3f2a1c4e-8b9d-4e7a-9f1c-2d6e5a8b3c10" resp = requests.post( f"https://api.knoxcall.com/v1/ai-gateway/gateways/{gw}/agents", headers={"Authorization": f"Bearer {TOKEN}"}, json={ "name": "Support Bot", "slug": "support-bot", "default_model": "claude-sonnet-4-6", "budget_daily_usd": 10.00, "budget_monthly_usd": 200.00, }, ) agent = resp.json()["data"] ``` **Response** — the created agent wrapped in `{ data, meta }`: ```json theme={"dark"} { "data": { "id": "7c4d2e9f-1a3b-4c6d-8e0f-2a4b6c8d0e1f", "tenant_id": "9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d", "gateway_id": "3f2a1c4e-8b9d-4e7a-9f1c-2d6e5a8b3c10", "name": "Support Bot", "slug": "support-bot", "description": "Handles tier-1 support questions", "primary_route_id": null, "fallback_route_ids": [], "model_allowlist": [], "model_denylist": [], "default_model": "claude-sonnet-4-6", "model_rewrite": {}, "budget_daily_usd": "10.00", "budget_monthly_usd": "200.00", "budget_per_call_max_tokens": null, "budget_overage_action": "block", "pii_redact_policy_id": null, "pii_detokenize_response": true, "pii_streaming_holdback_chars": 96, "cache_mode": "off", "streaming_enabled": true, "firewall_policy_id": null, "tool_allowlist": [], "output_schema": null, "output_validation_action": "warn", "status": "active", "paused_reason": null, "created_at": "2026-07-06T12:00:00.000Z", "updated_at": "2026-07-06T12:00:00.000Z", "created_by": "1b2c3d4e-5f6a-7b8c-9d0e-1f2a3b4c5d6e" }, "meta": { "request_id": "0d5b2a9e-1f3c-4a7d-8e2b-6c9a1f4d7e35" } } ``` Missing `name` or `slug` returns `400 validation`; an invalid slug returns `422 invalid_slug`; a duplicate slug returns `409 conflict`. ## Get an agent ```http theme={"dark"} GET /v1/ai-gateway/agents/{agentId} ``` Returns a single agent. Requires the `read` capability. Returns `404 not_found` if the agent does not belong to your tenant or is a system agent. ```bash theme={"dark"} curl https://api.knoxcall.com/v1/ai-gateway/agents/7c4d2e9f-1a3b-4c6d-8e0f-2a4b6c8d0e1f \ -H "Authorization: Bearer $TOKEN" ``` ## Update an agent ```http theme={"dark"} PATCH /v1/ai-gateway/agents/{agentId} ``` Partial update — send only the fields you want to change. Requires the `write` capability. The `slug` cannot be changed. **Updatable fields** `name`, `description`, `primary_route_id`, `default_model`, `model_allowlist`, `model_denylist`, `budget_daily_usd`, `budget_monthly_usd`, `streaming_enabled`, `firewall_policy_id`, `pii_redact_policy_id`, `pii_detokenize_response`, `tool_allowlist`. Reference fields (`primary_route_id`, `firewall_policy_id`, `pii_redact_policy_id`) are tenant-ownership checked exactly as on create. ```bash theme={"dark"} curl -X PATCH https://api.knoxcall.com/v1/ai-gateway/agents/7c4d2e9f-1a3b-4c6d-8e0f-2a4b6c8d0e1f \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "default_model": "claude-opus-4-6", "budget_daily_usd": 25.00, "model_denylist": ["gpt-4o"] }' ``` The response is the updated agent wrapped in `{ data, meta }`. ## Archive an agent ```http theme={"dark"} DELETE /v1/ai-gateway/agents/{agentId} ``` Soft-deletes (archives) the agent and stops it serving data-plane traffic. Requires the `write` capability. ```bash theme={"dark"} curl -X DELETE https://api.knoxcall.com/v1/ai-gateway/agents/7c4d2e9f-1a3b-4c6d-8e0f-2a4b6c8d0e1f \ -H "Authorization: Bearer $TOKEN" ``` **Response** ```json theme={"dark"} { "data": { "id": "7c4d2e9f-1a3b-4c6d-8e0f-2a4b6c8d0e1f", "status": "archived" }, "meta": { "request_id": "0d5b2a9e-1f3c-4a7d-8e2b-6c9a1f4d7e35" } } ``` # Create Agent Source: https://docs.knoxcall.com/api-reference/ai-gateway/agents-create POST /ai-gateway/gateways/{gatewayId}/agents Create an AI agent within a gateway, bound to a provider and upstream secret, with optional default model and budget configuration. The `/v1` operation above is the one to write against — it is what the five SDKs, the Postman collection and this spec cover. Most of an agent's configuration is applied **after** creation with [Update agent](/api-reference/ai-gateway/agents-update); see [the agent object](/api-reference/ai-gateway/agents#the-agent-object) for the full field list. ## The dashboard twin ### Create agent ```http theme={"dark"} POST /admin/ai-gateway/{gatewayId}/agents ``` The same operation exists on the **admin host** (`admin.knoxcall.com`), where the dashboard calls it. Authenticate with a session JWT, send the `X-Tenant-ID` header, and use an owner or admin account — it is **not** an `api.knoxcall.com` API-key endpoint. The fields below describe **that** endpoint. **Request body** ```json theme={"dark"} { "name": "Customer Support Bot", "slug": "support-bot", "provider": "anthropic", "upstream_secret_id": "00000000-0000-0000-0000-000000000000", "description": "Handles tier-1 support questions", "default_model": "claude-sonnet-4-6", "budget_daily_usd": 10.00, "budget_monthly_usd": 200.00, "streaming_enabled": true } ``` **Create fields** | Field | Type | Description | | - | - | - | | `name` | string | **Required.** Display name for the agent. | | `slug` | string | **Required.** URL slug; the agent is served at `/v1/ai/{slug}`. | | `provider` | string | **Required** (unless you pass `primary_route_id`). Any id in the server-side catalog — fourteen at the time of writing: `anthropic`, `openai`, `gemini`, `cohere`, `azure-openai`, `ollama`, `groq`, `together`, `mistral`, `deepseek`, `fireworks`, `xai`, `bedrock`, `openai-compatible`. Determines the upstream URL and the auth header template. An unknown value returns a 400 naming the valid set, so read that rather than mirroring this list in your own code. | | `upstream` | string (url) | **Required for the four providers whose endpoint is yours**, not the vendor's: `azure-openai`, `ollama`, `bedrock`, `openai-compatible`. Resolved and SSRF-checked at write time for `bedrock` and `openai-compatible` — a private, loopback or link-local address is a 400, because the request would carry your decrypted provider key there. | | `upstream_secret_id` | string (uuid) | **Required.** ID of a secret in this tenant holding the provider API key. The gateway auto-creates an upstream route (`ai-gateway-{slug}`) wired to this secret. | | `description` | string | Optional free-text description. | | `default_model` | string | Optional. Defaults to that provider's pricebook default. **Required** for `openai-compatible`, which has no default because the endpoint is yours. | | `budget_daily_usd` | number | Optional daily spend cap. | | `budget_monthly_usd` | number | Optional monthly spend cap. | | `streaming_enabled` | boolean | Optional. Defaults to `true`. | The primary route is derived automatically from `provider` + `upstream_secret_id`; you do not pass `primary_route_id` at create time. Model policy (`model_allowlist`, `model_denylist`, `model_rewrite`), `tool_allowlist`, `output_schema` / `output_validation_action`, `fallback_route_ids`, and `firewall_policy_id` are configured **after** creation via `PATCH /admin/ai-gateway/{gatewayId}/agents/{agentId}` — they are not read by the create handler. # Archive Agent Source: https://docs.knoxcall.com/api-reference/ai-gateway/agents-delete DELETE /ai-gateway/agents/{id} Take an agent out of service and disable the upstream route it orphans. Sets the agent's status to `archived`. Its `/v1/ai/{slug}` route stops serving — requests are refused with `503 agent_not_active`. Archiving also **disables the upstream `routes` row it orphans**, scoped to the data space you archived in, so archiving a sandbox agent does not disable the Live route. Before 2026-08-30 the route stayed enabled and the upstream remained reachable through an agent you had archived. Existing phantom tokens for the agent are **not** revoked — they simply have nothing to reach. Revoke them explicitly if the credential itself is the concern: [Revoke a phantom token](/api-reference/ai-gateway/tokens-revoke). Requires the `delete` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # Get Agent Source: https://docs.knoxcall.com/api-reference/ai-gateway/agents-get GET /ai-gateway/agents/{id} Read one agent and its full configuration. Returns the agent with every setting at its configured or default value — routing, model policy, budgets, PII and firewall policy ids, cache mode, output schema, residency. An agent belonging to another tenant, or a KnoxCall system agent, returns `404 not_found`: a probe cannot distinguish "not yours" from "does not exist". Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # List Agents Source: https://docs.knoxcall.com/api-reference/ai-gateway/agents-list GET /ai-gateway/gateways/{gatewayId}/agents Every agent under one gateway. Returns the gateway's agents, paginated. Each is served on the data plane at `/v1/ai/{slug}` — the `agent_url` field is the base URL to point an SDK at. KnoxCall-managed **system agents** (the Workflows system agent, for example) are never returned here, and every other `/v1` operation on one answers `404 not_found`. For the full field list, see [the agent object](/api-reference/ai-gateway/agents#the-agent-object). Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # Update Agent Source: https://docs.knoxcall.com/api-reference/ai-gateway/agents-update PATCH /ai-gateway/agents/{id} Partially update an agent — routing, model policy, budgets, guardrails. Partial update. Omitted fields are left as they are. This is where most of an agent's configuration is set: everything beyond the handful of fields create accepts is applied here. Two changes have effects worth knowing before you make them: * **`status: "paused"`** refuses the agent's traffic with `503 agent_not_active` on `/v1/ai`, and JSON-RPC `-32603` on `/v1/mcp`. * **`guardrail_webhook_url`** is resolved and refused at write time through the SSRF chokepoint, and may not carry credentials in the URL (`https://user:pass@host/`) — a credential written into a URL is stored in plaintext and leaks into every log line that echoes it. Setting `firewall_policy_id` to a policy with `action: "block"` is what makes the firewall refuse rather than warn — see [Prompt firewall](/ai-gateway/firewall). Requires the `update` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # Control Plane (v1) Source: https://docs.knoxcall.com/api-reference/ai-gateway/control-plane-overview Manage AI Gateways, agents, phantom tokens, and usage over the public /v1 Management API — authenticated with the SDK, an OAuth 2.1 token, or an API key. # AI Gateway control plane The AI Gateway control plane lets you create and manage gateways, agents, and phantom tokens — and read cost/usage — over the public **Management API** at `api.knoxcall.com/v1`. These are the same resources you configure in the dashboard, exposed as REST endpoints so you can provision them from code, CI, or the KnoxCall SDKs. This is the **control plane** — it configures the gateway. To actually send AI traffic through an agent, call the **data plane** described in [Execute AI Request](/api-reference/ai-gateway/execute). The data plane lives on your tenant proxy subdomain and authenticates with a phantom token, not an API key. The dashboard/admin surface documented on the [List Gateways](/api-reference/ai-gateway/gateways-list), [Create Agent](/api-reference/ai-gateway/agents-create), and [Mint Phantom Token](/api-reference/ai-gateway/tokens-mint) pages performs the same operations from a session-authenticated `/admin/ai-gateway` host. The `/v1/ai-gateway` endpoints below are the public equivalent and are what the KnoxCall SDKs call. ## Base URL All control-plane endpoints are served under `/v1/ai-gateway`. ```text theme={"dark"} https://api.knoxcall.com/v1/ai-gateway ``` Authenticate with a live-mode OAuth token (`kc_live_…`) or a legacy production key (`tk_live_…` / `AKE…`). ```text theme={"dark"} https://sandbox.knoxcall.com/v1/ai-gateway ``` Authenticate with a test-mode OAuth token (`kc_test_…`) or a legacy test key (`tk_test_…`). ## Authentication Authenticate exactly as you do for the rest of the Management API. The recommended path is the KnoxCall SDK — which mints, caches, and refreshes short-lived OAuth 2.1 access tokens for you — or a token you mint yourself; long-lived API keys remain supported as the legacy alternative. See the [Authentication guide](/api-reference/authentication) for the full picture. | Method | Header | Notes | | - | - | - | | OAuth 2.1 (**use this**) | `Authorization: Bearer kc_live_…` | Short-lived token minted via `POST /oauth/token`; tenant is resolved from the token. The SDKs handle minting and refresh for you, and every example on these pages uses it. | | API key (legacy) | `Authorization: Bearer tk_live_…` | Tenant is resolved from the key. Long-lived: prefer OAuth for anything new, and reach for a key only where you cannot run a mint step. | The `x-api-key` header is also accepted on the Management API for backwards compatibility, but it is not shown in these examples and new integrations should not use it. `Authorization: Bearer` is the one header every credential family works with. Neither of these is the data-plane credential. `/v1/ai/{slug}` takes a **phantom token** (`kp_…`) — a different credential family entirely. See [Execute AI Request](/api-reference/ai-gateway/execute). The curl examples on the AI Gateway reference pages authenticate with `$TOKEN`, a 1-hour OAuth access token minted once: ```bash theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) ``` ### Required scope Every endpoint is gated on the `ai_gateway` scope. The action determines the capability your key must hold: | Operation | HTTP | Capability on `ai_gateway` | | - | - | - | | List / read a gateway, agent, token, or usage | `GET` | `read` | | Create / update / delete a gateway or agent | `POST` / `PATCH` / `DELETE` | `write` | | Mint a phantom token | `POST …/tokens` | `mint` | A key that lacks the required capability receives `403` with error type `forbidden`. No other scope grants access to these endpoints. ## Response envelope Every response uses the standard Management API envelope. **Single resource** — `{ data, meta }`: ```json theme={"dark"} { "data": { "id": "3f2a1c4e-8b9d-4e7a-9f1c-2d6e5a8b3c10", "name": "Production" }, "meta": { "request_id": "0d5b2a9e-1f3c-4a7d-8e2b-6c9a1f4d7e35" } } ``` **List** — `{ data: [...], meta }` with pagination fields: ```json theme={"dark"} { "data": [ /* … */ ], "meta": { "total": 3, "page": 1, "per_page": 20, "total_pages": 1, "request_id": "0d5b2a9e-1f3c-4a7d-8e2b-6c9a1f4d7e35" } } ``` **Error** — `{ error }`: ```json theme={"dark"} { "error": { "type": "not_found", "message": "Gateway not found.", "request_id": "0d5b2a9e-1f3c-4a7d-8e2b-6c9a1f4d7e35" } } ``` ## Pagination List endpoints accept `page` and `per_page` query parameters and slice the full tenant result set. | Parameter | Default | Max | | - | - | - | | `page` | `1` | — | | `per_page` | `20` | `100` | The `meta` object returns `total`, `page`, `per_page`, and `total_pages`. ## Sandbox and phantom-token environments Gateways and agents are **not** environment-partitioned — the same gateway and agent objects are visible whether you authenticate with a live or a test credential. The live/test distinction lives in the **phantom token**: a token minted with a test-mode credential is a test-environment token (`kp_test_…`), and a token minted with a live credential is a live token (`kp_live_…`). There is no environment parameter on any request — the server derives it from the credential you used to mint. Note the two prefixes are different credential families and are not interchangeable: `kc_` is an OAuth 2.1 access token for **this** Management API, `kp_` is a phantom token for the **data plane** (`/v1/ai/{slug}`). Presenting one where the other is expected is refused, not translated. ## Idempotency Mutating requests (`POST`, `PATCH`, `DELETE`) honour the router-level `X-Idempotency-Key` header. Send the same key to retry a request safely without creating duplicates. ## Errors In addition to the [shared authentication errors](/api-reference/authentication#authentication-errors), the control plane returns: | Type | HTTP | When | | - | - | - | | `validation` | 400 | A required field (e.g. `name`, `slug`, or `dpop_jkt` when `dpop_required`) is missing. | | `invalid_reference` | 400 | `primary_route_id`, `firewall_policy_id`, or `pii_redact_policy_id` does not reference a resource owned by your tenant. | | `invalid_id` | 400 | A path or query identifier is not a valid UUID. | | `forbidden` | 403 | The key lacks the required `ai_gateway` capability. | | `not_found` | 404 | The gateway, agent, or token does not exist for this tenant. | | `conflict` | 409 | A gateway or agent with that slug already exists for this tenant. | | `invalid_slug` | 422 | The slug is not 2–64 lowercase alphanumerics/hyphens starting and ending with an alphanumeric. | | `internal_error` | 500 | Unexpected server error — contact support with the `request_id`. | ## Endpoint index | Method & path | Purpose | | - | - | | `GET /v1/ai-gateway/gateways` | [List gateways](/api-reference/ai-gateway/gateways#list-gateways) | | `POST /v1/ai-gateway/gateways` | [Create a gateway](/api-reference/ai-gateway/gateways#create-a-gateway) | | `GET /v1/ai-gateway/gateways/{id}` | [Get a gateway](/api-reference/ai-gateway/gateways#get-a-gateway) | | `PATCH /v1/ai-gateway/gateways/{id}` | [Update a gateway](/api-reference/ai-gateway/gateways#update-a-gateway) | | `DELETE /v1/ai-gateway/gateways/{id}` | [Archive a gateway](/api-reference/ai-gateway/gateways#archive-a-gateway) | | `GET /v1/ai-gateway/gateways/{gatewayId}/agents` | [List agents](/api-reference/ai-gateway/agents#list-agents) | | `POST /v1/ai-gateway/gateways/{gatewayId}/agents` | [Create an agent](/api-reference/ai-gateway/agents#create-an-agent) | | `GET /v1/ai-gateway/agents/{agentId}` | [Get an agent](/api-reference/ai-gateway/agents#get-an-agent) | | `PATCH /v1/ai-gateway/agents/{agentId}` | [Update an agent](/api-reference/ai-gateway/agents#update-an-agent) | | `DELETE /v1/ai-gateway/agents/{agentId}` | [Archive an agent](/api-reference/ai-gateway/agents#archive-an-agent) | | `GET /v1/ai-gateway/agents/{agentId}/tokens` | [List tokens](/api-reference/ai-gateway/tokens#list-tokens) | | `POST /v1/ai-gateway/agents/{agentId}/tokens` | [Mint a token](/api-reference/ai-gateway/tokens#mint-a-token) | | `DELETE /v1/ai-gateway/agents/{agentId}/tokens/{tokenId}` | [Revoke a token](/api-reference/ai-gateway/tokens#revoke-a-token) | | `GET /v1/ai-gateway/usage` | [Usage & cost](/api-reference/ai-gateway/usage) | # Execute AI Request Source: https://docs.knoxcall.com/api-reference/ai-gateway/execute POST /v1/ai/{agentSlug}/{upstreamPath} Proxy an AI request through the gateway with phantom-token auth, streaming PII redaction, prompt firewall, budget enforcement, and shape translation. # AI Gateway The AI Gateway is KnoxCall's AI-native proxy layer. It sits between your applications and upstream AI providers (Anthropic and OpenAI are the configurable providers; Bedrock is supported only as a shape-translation target), providing: * **Phantom tokens** — short-lived DPoP-bound capability tokens; your application never holds a raw provider API key. * **Streaming PII redaction** — Aho-Corasick + Presidio detector stack with a configurable hold-back buffer. * **Prompt injection firewall** — built-in heuristic patterns + tenant-supplied regex/keyword rules; optional canary token injection detects system-prompt extraction. * **Budget enforcement** — per-agent daily/monthly USD caps with configurable overage actions. * **Shape translation** — automatic Anthropic ↔ OpenAI request/response translation when routes target a different provider format. * **Compliance packs** — HIPAA Safe Harbor, GDPR, PCI-DSS, and SOC 2 packs install recognizers and audit-log alert rules in one operation. *** ## Authentication All AI Gateway proxy endpoints authenticate via **phantom tokens** — capability tokens minted for a specific agent. Send the token via any of the three accepted header schemes: ```http theme={"dark"} Authorization: Bearer kp_live_a_... ``` ```http theme={"dark"} x-api-key: kp_live_a_... ``` ```http theme={"dark"} x-knox-ai-key: kp_live_a_... ``` For DPoP-bound tokens, you must additionally include a valid `DPoP` header on every request. Gateway and agent CRUD, token minting, firewall policies, MCP servers and usage are **control-plane** operations and are served on two different surfaces: | Surface | Base | Credential | | - | - | - | | Public Management API (**use this**) | `https://api.knoxcall.com/v1/ai-gateway` | An OAuth 2.1 access token (`Authorization: Bearer kc_live_…`), or a legacy `tk_…` / `AKE…` API key. Covered by all five SDKs and the [control-plane overview](/api-reference/ai-gateway/control-plane-overview). | | Dashboard admin API | `/admin/ai-gateway` on the admin host | A session-authenticated request with the `X-Tenant-ID` header and an owner/admin role. This is what the dashboard itself calls; a few operations exist only here (they are called out where that is the case). | An earlier revision of this page said the control plane requires a session JWT and **not** an API key. That has been false since `/v1/ai-gateway/*` shipped: the `/v1` surface authenticates with the same credentials as the rest of the Management API. *** ## Execute AI request ```http theme={"dark"} POST https://{slug}.knoxcall.com/v1/ai/{agent-slug}/{...path} ``` The execute (data-plane) endpoint is served on your tenant's proxy subdomain — `https://{slug}.knoxcall.com` (sandbox: `https://sandbox-{slug}.knoxcall.com`), **not** `api.knoxcall.com`. Use the `agent_url` returned when the agent was created as the base URL. Forwards the request to the agent's configured primary route. The request body must be a valid JSON body for the upstream provider (Anthropic Messages API, OpenAI Chat Completions API, etc.). If the agent has `streaming_enabled` and the request asks the provider to stream — `"stream": true` in the body, or Gemini's `:streamGenerateContent` endpoint — the response is streamed as SSE. An `Accept: text/event-stream` header on its own does not stream: it asks nothing of the provider, so the response is served buffered (and fully redacted). Streaming is available on the endpoints whose event stream KnoxCall redacts mid-stream — Anthropic `/v1/messages`, OpenAI-compatible `/chat/completions`, Gemini `:streamGenerateContent` — and is refused with `400 streaming_redaction_unsupported` elsewhere (for example the OpenAI Responses API); send those requests without `stream: true`. ### Request headers | Header | Description | | - | - | | `Authorization: Bearer kp_live_a_...` | Phantom token for this agent. May alternatively be sent via the `x-api-key` or `x-knox-ai-key` header. | | `DPoP` | DPoP proof JWT (required when the token has `dpop_required: true`). | | `X-KC-User` | Optional. The person's workspace-directory key, for per-user cost attribution: their SCIM `externalId` (or `userName` when the IdP sends none), otherwise a member's KnoxCall email. Resolves only while that person is a member; otherwise the response carries `X-Knox-AI-Attribution: user_unregistered`. | | `X-KC-Conversation-Id` | Optional. Conversation identifier for PII token-map scoping across turns. | ### Response headers | Header | Description | | - | - | | `X-Request-Id` | UUID identifying this proxy call (appears in audit logs). | | `X-Knox-AI-Budget-Pct` | Current budget utilization percentage (when budget is configured). | | `X-Knox-AI-Budget-Warning` | Present when utilization exceeds the warn threshold. | | `X-Knox-AI-Tools-Stripped` | Comma-separated names of tools removed by the agent's tool allowlist. | | `X-Knox-AI-Output-Warning` | Present when the response fails output schema validation but `output_validation_action` is `warn`. | | `X-Knox-AI-Retry` | Set to `output_schema` when the response was automatically retried due to schema violation. | ### Firewall responses When the prompt injection firewall blocks a request, `HTTP 400` in the data plane's [one error envelope](/ai-gateway/errors): ```json theme={"dark"} { "error": "firewall_block", "error_description": "Request blocked by firewall policy: ignore_previous_instructions, dan_jailbreak", "code": "firewall_block" } ``` `error` and `code` always carry the same identifier — branch on either. Every refusal this endpoint can return, and whether to retry it, is on the [errors page](/ai-gateway/errors). The firewall **always runs** — the built-in heuristics cannot be disabled and they scan every request. What a policy decides is the **outcome**. With no firewall policy attached to the agent, the outcome is capped at `warn`: a match is recorded and the request still reaches the upstream. `block` requires a firewall policy attached to the agent with `action: "block"`. Create one via [`POST /v1/ai-gateway/firewall-policies`](/api-reference/ai-gateway/firewall-policies-create) and set the agent's `firewall_policy_id`. *** ## Shape translation When the caller's provider format differs from the route's target provider format, the AI Gateway translates automatically: | From | To | What changes | | - | - | - | | Anthropic | OpenAI | `system` field → system message; `input_schema` → `parameters`; tool choice shape | | OpenAI | Anthropic | Leading system message → `system` field; `parameters` → `input_schema` | | Any | Bedrock | Strips `model` from body; adds `anthropic_version: "bedrock-2023-05-31"` | Format is detected from the route's `target_base_url` (e.g. `api.anthropic.com` → Anthropic format). Response shapes are translated back to match the caller's expected format on the buffered path. *** ## Canary token injection When a firewall policy has `canary_enabled: true`, the gateway injects a `kc_canary_<16hex>` token into every system prompt before forwarding upstream. If the model echoes the token verbatim in its response (a sign of system-prompt extraction via prompt injection), the gateway: 1. Emits an `ai_gateway.canary_leak` audit log entry at `critical` severity. 2. Records the **policy's own action** as `firewall_outcome` on the usage record — `block` when the attached policy's action is `block`, otherwise `warn`. (Before AIGW-171 this was hard-coded to `warn`, so a tenant who had configured `block` read their own firewall report as saying the control had held.) 3. On a `block` policy, refuses the response rather than serving it. A canary echo is not a heuristic guess about intent: the token is a value KnoxCall generated, put in the system prompt, and never gave the caller. **Buffered**, that is `400 canary_leak`. **Streamed**, the `200` has already gone out, so the stream stops at the frame carrying the leak and ends with an `event: error` frame whose `code` is `canary_leak` — nothing after the leak is delivered, and anything sent before it contained no canary. The refusal reports a `matched_count`, never the token itself. 4. Fires any compliance-pack alert rules listening on `audit_action = 'ai_gateway.canary_leak'`. Either way the marker itself is spliced out of the body the caller receives (`[REDACTED:CANARY]`), on the buffered path and mid-stream — **except in the `monitor` streaming mode**, which passes the response through unredacted by design and only inspects it once it has finished. *** ## Compliance packs Install compliance packs via the [Packs API](/api-reference/packs) or from the Admin UI. Each pack installs: 1. **PII recognizers** — entity-type patterns for the detector stack. 2. **Alert rules** — `ai_gateway_event` alerts that fire when specific audit actions appear. Currently available packs: `hipaa-safe-harbor`, `gdpr`, `pci-dss`, `soc2`. *** ## OpenTelemetry export Set `OTEL_EXPORTER_OTLP_ENDPOINT` to enable per-request span export with GenAI semantic conventions: | Attribute | Value | | - | - | | `gen_ai.system` | Provider (e.g. `anthropic`) | | `gen_ai.request.model` | Requested model | | `gen_ai.usage.input_tokens` | Input token count | | `gen_ai.usage.output_tokens` | Output token count | | `knoxcall.ai_gateway.firewall_outcome` | `pass` / `warn` / `block` / `tag` | | `knoxcall.ai_gateway.pii_tokens_swapped` | Count of PII redactions | | `knoxcall.ai_gateway.cost_usd` | Estimated cost in USD | # Create a Firewall Policy Source: https://docs.knoxcall.com/api-reference/ai-gateway/firewall-policies-create POST /ai-gateway/firewall-policies Create a prompt-injection policy — and make `block` reachable for an agent. Creates a policy. `action` is what makes this endpoint matter: | `action` | What a match does | | - | - | | `block` | The request is refused with `400 firewall_block` and never reaches the provider. | | `warn` | The request proceeds; the match is recorded. | | `tag` | The request proceeds; the match is recorded as `tag`. | **With no policy attached to an agent, the outcome is `warn` and `block` is unreachable.** Creating a policy with `action: "block"` and setting the agent's `firewall_policy_id` is what turns detection into refusal. Every regex rule is compiled through a safe-regex validator at write time, so a pattern that could backtrack catastrophically is refused here rather than discovered on the hot path. Dry-run a rule set first with [Test firewall rules](/api-reference/ai-gateway/firewall-policies-test) — the tester compiles through the same validator, so a pattern it accepts can actually run. Requires the `create` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # Delete a Firewall Policy Source: https://docs.knoxcall.com/api-reference/ai-gateway/firewall-policies-delete DELETE /ai-gateway/firewall-policies/{id} Remove a firewall policy. Deletes the policy. Clear `firewall_policy_id` on every agent that references this policy **first**. An agent pointing at a policy that no longer exists is refused with `503 firewall_policy_missing` on every request — deliberately, because serving it would mean running with the firewall the operator configured silently absent. Requires the `delete` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # Get a Firewall Policy Source: https://docs.knoxcall.com/api-reference/ai-gateway/firewall-policies-get GET /ai-gateway/firewall-policies/{id} Read one firewall policy and its rules. Returns one policy owned by your tenant, with its rules and its `action`. Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # List Firewall Policies Source: https://docs.knoxcall.com/api-reference/ai-gateway/firewall-policies-list GET /ai-gateway/firewall-policies Every prompt-injection firewall policy for your tenant. Firewall policies are **tenant-scoped**, not gateway-scoped: one policy can be attached to agents across every gateway you own. Attach one by setting an agent's `firewall_policy_id`. Until you do, the built-in heuristics still run on that agent's traffic but the outcome is capped at `warn` — see [Prompt firewall](/ai-gateway/firewall). Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # Test Firewall Rules Source: https://docs.knoxcall.com/api-reference/ai-gateway/firewall-policies-test POST /ai-gateway/firewall-policies/test Dry-run a rule set against sample text before you attach it to anything. Runs a candidate `heuristics` array against a sample `text` and returns what matched. Nothing is stored and no policy is created. Two properties make the result trustworthy: * **It compiles through the same validator the writer uses.** A rule the tester refuses is a rule `POST /v1/ai-gateway/firewall-policies` would also refuse, so the tester can never green-light a pattern that could not run. * **It reports `skipped` rules explicitly.** A rule that fails to compile is named rather than silently ignored. On a live `block` policy a skipped rule is a fail-closed condition — the gateway refuses the request with `503 firewall_policy_degraded` rather than serve it under a policy it knows is only partly applied. Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # Update a Firewall Policy Source: https://docs.knoxcall.com/api-reference/ai-gateway/firewall-policies-update PATCH /ai-gateway/firewall-policies/{id} Change a policy's rules or its action — including switching it to `block`. Partial update. Omitted fields are left as they are. Changing `action` to `block` takes effect on every agent that references this policy, on their next request. Dry-run the rule set with [Test firewall rules](/api-reference/ai-gateway/firewall-policies-test) first — under `block`, a rule that will not compile stops being a warning and becomes a `503` on live traffic. Requires the `update` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # List Gateway Tokens Source: https://docs.knoxcall.com/api-reference/ai-gateway/gateway-tokens-list GET /ai-gateway/gateways/{gatewayId}/tokens Every phantom token under one gateway — including agentless tokens that no agent endpoint can reach. Lists the phantom tokens issued under this gateway: the tokens of every agent it owns, **and** any token minted without an agent at all. That second category is why this endpoint exists. A token minted through the [OIDC exchange](/ai-gateway/tokens-and-dpop#oidc-workload-federation-ci-without-static-secrets) with an RFC 8707 `resource` naming an MCP server has `agent_id: null`, so it is invisible to `GET /v1/ai-gateway/agents/{agentId}/tokens` — the only other place tokens are listed. Without this endpoint an agentless token could be neither inventoried nor found. Never returns plaintext; `prefix` is the first 12 characters, safe to display. Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # Revoke a Gateway Token Source: https://docs.knoxcall.com/api-reference/ai-gateway/gateway-tokens-revoke DELETE /ai-gateway/gateways/{gatewayId}/tokens/{tokenId} Revoke any token under a gateway, including an agentless one no agent endpoint can reach. Revokes a token that belongs to this gateway — either to one of its agents, or to no agent at all. Revocation takes effect immediately; in-flight requests that have already passed authentication complete. This is the **only** way to revoke an agentless token. Pair it with [List gateway tokens](/api-reference/ai-gateway/gateway-tokens-list) when you find a credential you cannot account for. Returns `404 not_found` if the token does not exist under this gateway, or is already revoked. There is no `POST` counterpart: `/v1` does not mint gateway-level tokens. Requires the `delete` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # Gateways (v1) Source: https://docs.knoxcall.com/api-reference/ai-gateway/gateways List, create, read, update, and archive AI Gateways over the public /v1 Management API. # Gateways A **gateway** is the top-level container for AI agents. It carries an optional daily/monthly USD budget that applies across its agents. These endpoints are the public `/v1` control-plane equivalent of the dashboard gateway pages — see the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the response envelope, and error types. The examples below authenticate with `$TOKEN`, a short-lived OAuth 2.1 access token minted there. Base path: `https://api.knoxcall.com/v1/ai-gateway/gateways` ## The gateway object | Field | Type | Description | | - | - | - | | `id` | string (uuid) | Gateway identifier. | | `tenant_id` | string (uuid) | Owning tenant. | | `name` | string | Display name. | | `slug` | string | URL-safe handle, unique per tenant. Set at creation and not editable. | | `description` | string \| null | Free-text description. | | `budget_daily_usd` | string \| null | Daily spend cap in USD (numeric, returned as a string). | | `budget_monthly_usd` | string \| null | Monthly spend cap in USD. | | `status` | string | One of `active`, `paused`, `archived`. | | `paused_reason` | string \| null | Set when the gateway is paused. | | `created_at` | string (ISO 8601) | Creation timestamp. | | `updated_at` | string (ISO 8601) | Last-update timestamp. | | `created_by` | string (uuid) \| null | API key / actor that created it. | ## List gateways ```http theme={"dark"} GET /v1/ai-gateway/gateways ``` Returns your tenant's gateways, paginated (`page`, `per_page`). Requires the `read` capability on `ai_gateway`. ```bash cURL theme={"dark"} curl "https://api.knoxcall.com/v1/ai-gateway/gateways?page=1&per_page=20" \ -H "Authorization: Bearer $TOKEN" ``` ```python Python theme={"dark"} import os import requests TOKEN = os.environ["KNOXCALL_ACCESS_TOKEN"] # OAuth access token — see Authentication resp = requests.get( "https://api.knoxcall.com/v1/ai-gateway/gateways", headers={"Authorization": f"Bearer {TOKEN}"}, params={"page": 1, "per_page": 20}, ) gateways = resp.json()["data"] ``` ```javascript Node.js theme={"dark"} const resp = await fetch( "https://api.knoxcall.com/v1/ai-gateway/gateways?page=1&per_page=20", { headers: { Authorization: `Bearer ${process.env.KNOXCALL_ACCESS_TOKEN}` } } ); const { data: gateways } = await resp.json(); ``` **Response** ```json theme={"dark"} { "data": [ { "id": "3f2a1c4e-8b9d-4e7a-9f1c-2d6e5a8b3c10", "tenant_id": "9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d", "name": "Production", "slug": "production", "description": null, "budget_daily_usd": "50.00", "budget_monthly_usd": "1000.00", "status": "active", "paused_reason": null, "created_at": "2026-05-01T00:00:00.000Z", "updated_at": "2026-05-01T00:00:00.000Z", "created_by": "1b2c3d4e-5f6a-7b8c-9d0e-1f2a3b4c5d6e" } ], "meta": { "total": 1, "page": 1, "per_page": 20, "total_pages": 1, "request_id": "0d5b2a9e-1f3c-4a7d-8e2b-6c9a1f4d7e35" } } ``` ## Create a gateway ```http theme={"dark"} POST /v1/ai-gateway/gateways ``` Requires the `write` capability on `ai_gateway`. **Request body** | Field | Type | Description | | - | - | - | | `name` | string | **Required.** Display name. | | `slug` | string | **Required.** 2–64 lowercase alphanumerics/hyphens; must start and end with an alphanumeric. Unique per tenant. | | `description` | string | Optional. | | `budget_daily_usd` | number | Optional daily spend cap. | | `budget_monthly_usd` | number | Optional monthly spend cap. | ```bash cURL theme={"dark"} curl -X POST https://api.knoxcall.com/v1/ai-gateway/gateways \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Production", "slug": "production", "description": "Production AI egress", "budget_daily_usd": 50.00, "budget_monthly_usd": 1000.00 }' ``` ```python Python theme={"dark"} import os import requests TOKEN = os.environ["KNOXCALL_ACCESS_TOKEN"] # OAuth access token — see Authentication resp = requests.post( "https://api.knoxcall.com/v1/ai-gateway/gateways", headers={"Authorization": f"Bearer {TOKEN}"}, json={ "name": "Production", "slug": "production", "description": "Production AI egress", "budget_daily_usd": 50.00, "budget_monthly_usd": 1000.00, }, ) gateway = resp.json()["data"] ``` **Response** — the created gateway wrapped in `{ data, meta }`: ```json theme={"dark"} { "data": { "id": "3f2a1c4e-8b9d-4e7a-9f1c-2d6e5a8b3c10", "tenant_id": "9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d", "name": "Production", "slug": "production", "description": "Production AI egress", "budget_daily_usd": "50.00", "budget_monthly_usd": "1000.00", "status": "active", "paused_reason": null, "created_at": "2026-07-06T12:00:00.000Z", "updated_at": "2026-07-06T12:00:00.000Z", "created_by": "1b2c3d4e-5f6a-7b8c-9d0e-1f2a3b4c5d6e" }, "meta": { "request_id": "0d5b2a9e-1f3c-4a7d-8e2b-6c9a1f4d7e35" } } ``` Missing `name` or `slug` returns `400 validation`; an invalid slug returns `422 invalid_slug`; a duplicate slug returns `409 conflict`. ## Get a gateway ```http theme={"dark"} GET /v1/ai-gateway/gateways/{id} ``` Returns a single gateway. Requires the `read` capability. Returns `404 not_found` if the gateway does not belong to your tenant. ```bash theme={"dark"} curl https://api.knoxcall.com/v1/ai-gateway/gateways/3f2a1c4e-8b9d-4e7a-9f1c-2d6e5a8b3c10 \ -H "Authorization: Bearer $TOKEN" ``` ## Update a gateway ```http theme={"dark"} PATCH /v1/ai-gateway/gateways/{id} ``` Partial update — all fields optional. Requires the `write` capability. The `slug` cannot be changed. **Request body** | Field | Type | Description | | - | - | - | | `name` | string | New display name. | | `description` | string | New description. | | `budget_daily_usd` | number | New daily cap. | | `budget_monthly_usd` | number | New monthly cap. | ```bash theme={"dark"} curl -X PATCH https://api.knoxcall.com/v1/ai-gateway/gateways/3f2a1c4e-8b9d-4e7a-9f1c-2d6e5a8b3c10 \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "budget_daily_usd": 75.00 }' ``` The response is the updated gateway wrapped in `{ data, meta }`. ## Archive a gateway ```http theme={"dark"} DELETE /v1/ai-gateway/gateways/{id} ``` Soft-deletes (archives) the gateway and returns its id and new status. Requires the `write` capability. ```bash theme={"dark"} curl -X DELETE https://api.knoxcall.com/v1/ai-gateway/gateways/3f2a1c4e-8b9d-4e7a-9f1c-2d6e5a8b3c10 \ -H "Authorization: Bearer $TOKEN" ``` **Response** ```json theme={"dark"} { "data": { "id": "3f2a1c4e-8b9d-4e7a-9f1c-2d6e5a8b3c10", "status": "archived" }, "meta": { "request_id": "0d5b2a9e-1f3c-4a7d-8e2b-6c9a1f4d7e35" } } ``` Archiving a gateway does not change the status of its agents. The data plane gates each request on the **agent's** status, so an agent that is still `active` keeps serving traffic. To stop traffic, [archive the agents directly](/api-reference/ai-gateway/agents#archive-an-agent). # Create Gateway Source: https://docs.knoxcall.com/api-reference/ai-gateway/gateways-create POST /ai-gateway/gateways Create an AI Gateway for your tenant. Creates a gateway. `slug` must be 2–64 lowercase alphanumerics or hyphens, starting and ending with an alphanumeric; anything else is `422 invalid_slug`, and a slug already in use is `409 conflict`. Creating a gateway also seeds a **Default** prompt-firewall policy for the tenant if one does not exist yet, and [Create an agent](/api-reference/ai-gateway/agents#create-an-agent) attaches it when the caller names no `firewall_policy_id`. That default policy's `action` is **`warn`**, deliberately: a policy silently inherited by every future agent must not be able to start refusing their traffic. So blocking is opt-in either way — change the Default policy's action, or attach one of your own with `action: "block"`. See [Prompt firewall](/ai-gateway/firewall). **The dashboard twin.** The same operation exists on the admin host as `POST /admin/ai-gateway`, session-authenticated with an `X-Tenant-ID` header and an owner/admin role. That is what the dashboard itself calls. Prefer the `/v1` operation above for anything you write: it is the surface the five SDKs, the Postman collection and this spec cover. # Archive Gateway Source: https://docs.knoxcall.com/api-reference/ai-gateway/gateways-delete DELETE /ai-gateway/gateways/{id} Archive an AI Gateway and disable the upstream routes it orphans. Sets the gateway's status to `archived` and returns the updated gateway. **Archiving a gateway does not archive its agents.** Each agent keeps its own status. Since 2026-08-27 an agent under an archived gateway is refused with `503 gateway_not_active`, so traffic does stop — but the agent rows are still `active`, and un-archiving the gateway resumes them. If you mean to retire the agents, archive them individually. Archiving also **disables the upstream `routes` rows it orphans**, scoped to the data space you archived in — so archiving a sandbox agent does not disable the Live route. Before 2026-08-30 the routes stayed enabled, which left the upstream reachable through a gateway you had archived. **The dashboard twin.** The same operation exists on the admin host as `DELETE /admin/ai-gateway/{gatewayId}`, session-authenticated with an `X-Tenant-ID` header and an owner/admin role. That is what the dashboard itself calls. Prefer the `/v1` operation above for anything you write: it is the surface the five SDKs, the Postman collection and this spec cover. # Get Gateway Source: https://docs.knoxcall.com/api-reference/ai-gateway/gateways-get GET /ai-gateway/gateways/{id} Retrieve a single AI Gateway. Returns one gateway owned by your tenant. A gateway belonging to another tenant returns `404 not_found`, so a probe cannot distinguish "not yours" from "does not exist". A malformed id is `400 invalid_id`. To list its agents, use [List agents](/api-reference/ai-gateway/agents#list-agents); its MCP servers, [List MCP servers](/api-reference/ai-gateway/mcp-servers-list); its tokens — including agentless ones — [List gateway tokens](/api-reference/ai-gateway/gateway-tokens-list). **The dashboard twin.** The same operation exists on the admin host as `GET /admin/ai-gateway/{gatewayId}`, session-authenticated with an `X-Tenant-ID` header and an owner/admin role. That is what the dashboard itself calls. Prefer the `/v1` operation above for anything you write: it is the surface the five SDKs, the Postman collection and this spec cover. # List Gateways Source: https://docs.knoxcall.com/api-reference/ai-gateway/gateways-list GET /ai-gateway/gateways Return every AI Gateway for your tenant, paginated. Returns your tenant's gateways, newest first, with `agent_count` on each. Gateways are **not** environment-partitioned: the same objects come back whether you authenticate with a live or a test credential. The live/test distinction lives in the phantom token — see [Sandbox and phantom-token environments](/api-reference/ai-gateway/control-plane-overview#sandbox-and-phantom-token-environments). **The dashboard twin.** The same operation exists on the admin host as `GET /admin/ai-gateway`, session-authenticated with an `X-Tenant-ID` header and an owner/admin role. That is what the dashboard itself calls. Prefer the `/v1` operation above for anything you write: it is the surface the five SDKs, the Postman collection and this spec cover. # Update Gateway Source: https://docs.knoxcall.com/api-reference/ai-gateway/gateways-update PATCH /ai-gateway/gateways/{id} Partially update an AI Gateway — every field is optional. Partial update. Omitted fields are left as they are. Setting `status` to `paused` stops the gateway's traffic: an agent under a paused gateway is refused with `503 gateway_not_active` on `/v1/ai`, and with JSON-RPC `-32603` on `/v1/mcp`. That has been true on both planes since 2026-08-27 — the MCP plane previously kept serving. **The dashboard twin.** The same operation exists on the admin host as `PATCH /admin/ai-gateway/{gatewayId}`, session-authenticated with an `X-Tenant-ID` header and an owner/admin role. That is what the dashboard itself calls. Prefer the `/v1` operation above for anything you write: it is the surface the five SDKs, the Postman collection and this spec cover. # List MCP Connections Source: https://docs.knoxcall.com/api-reference/ai-gateway/mcp-grants-list GET /ai-gateway/mcp-servers/{id}/grants Who has connected their own upstream account to a delegated-OAuth MCP server. A **connection** is one person's consent to let KnoxCall act as them against this server's upstream (`auth.mode: "delegated"`). KnoxCall holds their refresh token envelope-encrypted under your tenant key and mints a short-lived access token per call. **No response here carries a token** — not redacted, not truncated, absent. `has_refresh_token` is the only thing said about them; `false` means the connection cannot outlive its current access token and the person should reconnect. **Creating a connection is not an API operation.** Consent has to be given by the person whose credential it is, so the flow starts from a signed-in KnoxCall session on the MCP server's page in the console. An API key is not a person: an endpoint that started a flow "for alice\@acme" would let whoever holds the key finish it with their *own* upstream account, and the connection would hold the key holder's credential under Alice's name. Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # Revoke an MCP Connection Source: https://docs.knoxcall.com/api-reference/ai-gateway/mcp-grants-revoke DELETE /ai-gateway/mcp-servers/{id}/grants/{grantId} Destroy one person's stored upstream tokens for a delegated-OAuth MCP server. The stored refresh and access tokens are **destroyed**, not flagged: the table refuses a revoked row that still holds ciphertext. That person's next tool call on this server is refused until they connect again. This does **not** revoke KnoxCall's authorization at the upstream. If you need it gone there too, remove the app in the upstream's own account settings — only the account holder, or that provider's admin, can do that. Requires the `delete` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope and error types. # Revoke All MCP Connections Source: https://docs.knoxcall.com/api-reference/ai-gateway/mcp-grants-revoke-all DELETE /ai-gateway/mcp-servers/{id}/grants Offboarding in one call: revoke every delegated-OAuth connection on an MCP server. Revokes every connection on this MCP server and returns how many. Each one's stored tokens are destroyed rather than flagged. Archiving the server, or changing its `auth` configuration, does the same thing automatically — the stored tokens were issued for one OAuth app, one set of scopes and one upstream, so keeping them across a change would mean dialling with credentials minted under settings nobody can see any more. Requires the `delete` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope and error types. # Create an MCP Server Source: https://docs.knoxcall.com/api-reference/ai-gateway/mcp-servers-create POST /ai-gateway/gateways/{gatewayId}/mcp-servers Register an upstream MCP server under a gateway and choose how it authenticates upstream. Registers an upstream MCP server. Four things are worth knowing before you call it, each of which is a refusal rather than a surprise later: * **`allowed_tools` defaults to empty, and empty means *no tools*.** A server created without it advertises nothing on `tools/list` and refuses every `tools/call`. The response says so in `meta.note` when that happens. This is deliberate — a governed gateway does not open by default. * **`server_type: "collection"` is refused** with `422 unsupported_server_type`. The forwarder does not serve that type, so the row would fail every call. Only `upstream` is served. * **`transport` must be `streamable_http`.** Anything else is `422 unsupported_transport`; the gateway does not speak the legacy SSE transport. * **`upstream_url` goes through the SSRF chokepoint** at write time and again at connect time. A URL that resolves to a private or link-local address is refused, not dialled. `auth` selects how the server reaches its upstream: `{"mode": "secret"}` (the default) resolves one credential from your secret store per connection, and `{"mode": "passthrough"}` makes each caller supply their own — see [how the server authenticates upstream](/ai-gateway/mcp-gateway#how-the-server-authenticates-upstream). A literal credential in `auth.headers` is refused at write time; reference a Secret with `{{secret_id:}}`. MCP servers are a plan entitlement — creating one beyond your plan's allowance is refused. Requires the `create` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # Archive an MCP Server Source: https://docs.knoxcall.com/api-reference/ai-gateway/mcp-servers-delete DELETE /ai-gateway/mcp-servers/{id} Take an MCP server out of service. Archives the server. Its `/v1/mcp/{slug}` route stops resolving, and calls to it are refused rather than served from a stale upstream. Requires the `delete` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # Get an MCP Server Source: https://docs.knoxcall.com/api-reference/ai-gateway/mcp-servers-get GET /ai-gateway/mcp-servers/{id} Read one MCP server, without its upstream credential. Returns one MCP server owned by your tenant. The stored upstream credential is never included in the response — only the auth *mode*. Returns `404 not_found` if the server belongs to another tenant, so a probe cannot distinguish "not yours" from "does not exist". Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # List MCP Servers Source: https://docs.knoxcall.com/api-reference/ai-gateway/mcp-servers-list GET /ai-gateway/gateways/{gatewayId}/mcp-servers The MCP servers registered under a gateway. Returns the gateway's MCP servers, paginated. Each is served on the data plane at `/v1/mcp/{slug}` — see [MCP gateway](/ai-gateway/mcp-gateway) for what the gateway does to a `tools/call` on the way through. A server's stored upstream credential is never returned; `auth` comes back with its mode and nothing sensitive. Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # Update an MCP Server Source: https://docs.knoxcall.com/api-reference/ai-gateway/mcp-servers-update PATCH /ai-gateway/mcp-servers/{id} Change a server's upstream, tool allowlist, PII inspection or auth mode. Partial update. Omitted fields are left as they are. The same write-time refusals apply as on [create](/api-reference/ai-gateway/mcp-servers-create): a new `upstream_url` is re-validated through the SSRF chokepoint, a literal credential in `auth.headers` is refused, and userinfo in the URL (`https://user:pass@host/`) is rejected — a credential written into a URL lands in plaintext in every log line and error message that echoes it. Setting `allowed_tools` to `[]` takes every tool off the server. That is a valid thing to want and it is not a soft state: the server will advertise nothing. Requires the `update` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # Delete an MCP Tool Source: https://docs.knoxcall.com/api-reference/ai-gateway/mcp-tools-delete DELETE /ai-gateway/mcp-servers/{id}/tools/{toolId} Remove a recorded tool definition from an MCP server. Removes the tool row. If the tool name is still in the server's `allowed_tools`, the allowlist entry survives this call — remove it there too, or the name stays permitted the moment the upstream advertises it again. Requires the `delete` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # List MCP Tools Source: https://docs.knoxcall.com/api-reference/ai-gateway/mcp-tools-list GET /ai-gateway/mcp-servers/{id}/tools The tool definitions recorded for one MCP server. Returns the tool rows KnoxCall holds for this server. These are **not** the same thing as what a client sees from `tools/list` on the data plane. That response is the upstream's advertised tools intersected with the server's `allowed_tools` **and** the presenting token's own `tools` scope — so a tool listed here can still be invisible, and unreachable, to a particular credential. Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # Update an MCP Tool Source: https://docs.knoxcall.com/api-reference/ai-gateway/mcp-tools-update PATCH /ai-gateway/mcp-servers/{id}/tools/{toolId} Change one recorded tool definition. Partial update of a single tool row. Omitted fields are left as they are. Requires the `update` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # Add or Replace an MCP Tool Source: https://docs.knoxcall.com/api-reference/ai-gateway/mcp-tools-upsert POST /ai-gateway/mcp-servers/{id}/tools Record a tool definition on an MCP server. Creates the tool row, or replaces it if one with the same name already exists. Recording a tool here does not by itself expose it. A tool is reachable only when it is also in the server's `allowed_tools` — the allowlist is the control, and this endpoint is the catalogue. Requires the `update` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # Workload Token Exchange Source: https://docs.knoxcall.com/api-reference/ai-gateway/oauth-token Exchange a CI system's OIDC id_token for a short-lived phantom token — RFC 8693 token exchange on the AI Gateway data plane, with no stored secret in the pipeline. ### Workload token exchange ```http theme={"dark"} POST https://{tenant-slug}.knoxcall.com/v1/oauth/token ``` Exchange a workload's OIDC **id\_token** — a GitHub Actions token, a GCP or AWS workload identity token, an Azure managed-identity token — for a short-lived KnoxCall phantom token, using [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchange. Nothing long-lived is stored in the pipeline. **This is not the OAuth 2.1 token endpoint.** `POST https://api.knoxcall.com/oauth/token` mints management-API access tokens for `/v1/*`. This endpoint lives on your **tenant's data-plane subdomain** under `/v1/oauth/token`, and mints **phantom tokens** for `/v1/ai/` and `/v1/mcp/`. The two are separate endpoints with separate credentials. The endpoint is **unauthenticated by design** — the subject token *is* the credential, and verifying it is what the endpoint is being asked to do. **Prerequisite: an OIDC binding** An exchange only succeeds against a binding you have configured (the agent's Federation section, or the admin API). A binding pins: * the trusted **issuer**, whose JWKS is fetched over HTTPS from its published discovery document; * the required **attribute conditions** — for example a specific repository, ref or environment; * the **capability scope** and TTL of the token that gets minted. A binding with **zero attribute conditions is refused**. A binding that matched any token from an issuer would hand a KnoxCall credential to every repository on GitHub. **Request** `application/x-www-form-urlencoded` (JSON is also accepted). ```http theme={"dark"} POST /v1/oauth/token Content-Type: application/x-www-form-urlencoded grant_type=urn:ietf:params:oauth:grant-type:token-exchange &subject_token= &subject_token_type=urn:ietf:params:oauth:token-type:id_token ``` | Field | Required | Notes | | - | - | - | | `grant_type` | yes | Must be `urn:ietf:params:oauth:grant-type:token-exchange`. | | `subject_token` | yes | The workload's OIDC id\_token (a compact JWS). | | `subject_token_type` | yes | Must be `urn:ietf:params:oauth:token-type:id_token`. | | `audience` | no | Defaults to KnoxCall's own audience; any other value is refused. | | `resource` | no | [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707) resource indicator — an MCP resource URI on this host, e.g. `https://acme.knoxcall.com/v1/mcp/weather`. A single string only; an array or object is refused rather than silently dropped. | **Response** ```json theme={"dark"} { "access_token": "kp_live_a_...", "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", "token_type": "Bearer", "expires_in": 900, "scope": "{\"models\":[\"claude-sonnet-*\"]}" } ``` `expires_in` is the binding's `access_token_ttl_seconds`, default **900**. `scope` is a JSON serialisation for visibility only — the enforceable copy is bound into the token's capability HMAC and cannot be edited after minting. **What `resource` changes** | Called with | Token kind | Where it works | | - | - | - | | no `resource` | `agent` | `/v1/ai/` | | a `resource` | `tool` | **only** the named `/v1/mcp/` | A resource-bound token is confined to exactly that MCP server: the canonical resource URI is bound *into* the capability scope, so the HMAC covers it. The MCP plane requires a `tool`-kind token, and the AI plane refuses a token with no agent — so the kind narrows to match what you asked for, and never widens. **Single use** A given `subject_token` can be exchanged **once**. A replay returns `invalid_grant`, which is what stops a leaked CI log from being worth anything. **Errors** RFC 6749-shaped: `{ "error": "...", "error_description": "..." }`. | `error` | Cause | | - | - | | `unsupported_grant_type` | `grant_type` is not the token-exchange URN. | | `invalid_request` | Missing or malformed `subject_token`, wrong `subject_token_type`, or a subject token that is not a JWS triple. | | `invalid_target` | Unsupported `audience`, or a `resource` that is not a valid MCP resource URI on this host. | | `invalid_grant` | Signature verification failed, the token is expired or not yet valid, no enabled binding matches its issuer and attributes, or the subject token has already been exchanged. | | `server_error` | Issuer JWKS unreachable, or the tenant master key is unavailable. | **Rate limits** 30 requests/minute per (client IP, issuer, subject) and 60/minute per client IP. The client IP is deliberately part of the subject bucket: `iss` and `sub` come from an unverified JWT and are public for the primary use case, so keying on the claims alone would let anyone who knows your repository name lock out your real CI runs. See [Limits](/ai-gateway/limits#request-rate). **Example — GitHub Actions** ```yaml theme={"dark"} permissions: id-token: write steps: - name: Exchange for a KnoxCall phantom token run: | OIDC=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=knoxcall" | jq -r .value) KC=$(curl -s -X POST https://acme.knoxcall.com/v1/oauth/token \ -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \ -d subject_token="$OIDC" \ -d subject_token_type=urn:ietf:params:oauth:token-type:id_token \ | jq -r .access_token) curl https://acme.knoxcall.com/v1/ai/support-bot/v1/messages \ -H "Authorization: Bearer $KC" \ -H "content-type: application/json" \ -d '{"model":"claude-sonnet-5","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}' ``` See [Tokens & DPoP](/ai-gateway/tokens-and-dpop#oidc-workload-federation-ci-without-static-secrets) for the conceptual overview. # Create a PII Policy Source: https://docs.knoxcall.com/api-reference/ai-gateway/pii-policies-create POST /ai-gateway/pii-policies Create a PII redaction policy and choose which recognizers it runs. Creates a policy. Until this endpoint shipped, PII policies could only be created in the dashboard — so a `pii_redact_policy_id` was obtainable only by a UI detour, and a compliance-shaped setup could not be scripted end to end from an API key. It can now. Attach the policy by setting an agent's `pii_redact_policy_id` with [Update agent](/api-reference/ai-gateway/agents-update). Requires the `create` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # Delete a PII Policy Source: https://docs.knoxcall.com/api-reference/ai-gateway/pii-policies-delete DELETE /ai-gateway/pii-policies/{id} Remove a PII redaction policy. Deletes the policy. Clear `pii_redact_policy_id` on every agent that references this policy **first**. An agent pointing at a policy that no longer exists is refused with `503`, code `pii_policy_unavailable`, on every request — deliberately: serving it would mean forwarding prompts with the redaction the operator configured silently absent. Requires the `delete` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # Get a PII Policy Source: https://docs.knoxcall.com/api-reference/ai-gateway/pii-policies-get GET /ai-gateway/pii-policies/{id} Read one PII redaction policy. Returns one policy owned by your tenant. A policy belonging to another tenant returns `404 not_found`. Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # List PII Policies Source: https://docs.knoxcall.com/api-reference/ai-gateway/pii-policies-list GET /ai-gateway/pii-policies Every PII redaction policy for your tenant. PII policies are **tenant-scoped**: one policy can be attached to agents across every gateway you own. Attach one by setting an agent's `pii_redact_policy_id`. See [PII redaction](/ai-gateway/pii-redaction) for what a policy does to a request and a response. Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # Update a PII Policy Source: https://docs.knoxcall.com/api-reference/ai-gateway/pii-policies-update PATCH /ai-gateway/pii-policies/{id} Change a policy's recognizers or behaviour. Partial update. Omitted fields are left as they are. A change takes effect on the data plane within the recognizer cache's short TTL — the cache is invalidated on write, so it is effectively immediate. Requires the `update` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # Create a PII Recognizer Source: https://docs.knoxcall.com/api-reference/ai-gateway/pii-recognizers-create POST /ai-gateway/pii-recognizers Add a custom PII pattern for your tenant. Creates a recognizer. The pattern is compiled through a safe-regex validator at write time, so a pattern that could backtrack catastrophically is refused here rather than discovered on the hot path. Dry-run it first with [Test a recognizer pattern](/api-reference/ai-gateway/pii-recognizers-test) — the tester compiles through the same validator, so a pattern it accepts can actually run. A recognizer that fails to compile at request time is a **fail-closed** condition on the data plane: the request is refused with `503 pii_policy_degraded` rather than forwarded with part of the policy silently not running. Requires the `create` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # Delete a PII Recognizer Source: https://docs.knoxcall.com/api-reference/ai-gateway/pii-recognizers-delete DELETE /ai-gateway/pii-recognizers/{id} Remove a custom recognizer. Deletes the recognizer. Any policy that referenced it stops running that pattern on the data plane within the recognizer cache's short TTL — the cache is invalidated on write, so it is effectively immediate. Requires the `delete` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # Get a PII Recognizer Source: https://docs.knoxcall.com/api-reference/ai-gateway/pii-recognizers-get GET /ai-gateway/pii-recognizers/{id} Read one custom recognizer. Returns one recognizer owned by your tenant. One belonging to another tenant returns `404 not_found`. Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # List PII Recognizers Source: https://docs.knoxcall.com/api-reference/ai-gateway/pii-recognizers-list GET /ai-gateway/pii-recognizers Your tenant's custom PII recognizers. Returns your custom recognizers — the tenant-authored patterns that run alongside the built-in ones (customer numbers, internal ids, anything specific to your data). Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # Test a Recognizer Pattern Source: https://docs.knoxcall.com/api-reference/ai-gateway/pii-recognizers-test POST /ai-gateway/pii-recognizers/test Dry-run a PII pattern against sample text before you store it. Runs a candidate `pattern` against a sample `text` and returns what it matched. Nothing is stored and no recognizer is created. `pattern` and `text` are both required — an empty pattern is `400 invalid_pattern`, a missing `text` is `400 invalid_text`. Optional `kind`, `action`, `context_words` and `name` let you rehearse the exact recognizer you intend to save. It compiles through the same validator the writer uses, so it can never green-light a pattern that [create](/api-reference/ai-gateway/pii-recognizers-create) would refuse. This is the one operation here that is not tenant-scoped state: it reads nothing and writes nothing. It still requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # Update a PII Recognizer Source: https://docs.knoxcall.com/api-reference/ai-gateway/pii-recognizers-update PATCH /ai-gateway/pii-recognizers/{id} Change a recognizer's pattern, action or context words. Partial update. Omitted fields are left as they are. A new `pattern` is re-validated through the safe-regex compiler, so an update cannot install a pattern that a create would have refused. Requires the `update` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # Tokens (v1) Source: https://docs.knoxcall.com/api-reference/ai-gateway/tokens List, mint, and revoke an agent's phantom tokens over the public /v1 Management API. Plaintext is returned exactly once, at mint time. # Phantom tokens A **phantom token** is the short-lived capability token your application presents on the [data plane](/api-reference/ai-gateway/execute) to call an agent — your app never holds a raw provider API key. These endpoints manage the tokens for one agent over the `/v1` control plane. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication and the response envelope — the examples below authenticate with `$TOKEN`, a short-lived OAuth 2.1 access token minted there. The raw token value is returned **exactly once**, in the mint response. KnoxCall stores only a hash — list responses never contain plaintext. Save the minted token to your secrets manager immediately. ## Token format and environment Tokens are formatted `kp____`, e.g. `kp_live_a_1a2b3c…`. The `prefix` field is the first 12 characters (`kp_live_a_` plus two characters), safe to display and store for identification. **The prefix changed on 2026-09-01, with no compatibility window.** Phantom tokens used to be `kc_`-prefixed — the same family as OAuth 2.1 access tokens — and are now `kp_`. A token issued before that date no longer parses. Re-mint. See the [changelog](/ai-gateway/changelog#phantom-tokens-move-to-their-own-kp_-prefix). The environment segment is derived from the key you mint with — a test key mints a `kp_test_…` token, a live key mints a `kp_live_…` token. There is no environment parameter on the request. | `kind` | Prefix letter | Use | | - | - | - | | `agent` | `a` | Full-scope agent invocations (default). | | `read` | `r` | Read-only invocations. | | `tool` | `t` | Tool-bound. | | `oneshot` | `o` | One-shot ephemeral. | ## List tokens ```http theme={"dark"} GET /v1/ai-gateway/agents/{agentId}/tokens ``` Returns the agent's tokens, paginated (`page`, `per_page`), newest first. Requires the `read` capability. **Never returns plaintext.** Returns `404 not_found` if the agent does not belong to your tenant or is a system agent. ```bash theme={"dark"} curl "https://api.knoxcall.com/v1/ai-gateway/agents/7c4d2e9f-1a3b-4c6d-8e0f-2a4b6c8d0e1f/tokens" \ -H "Authorization: Bearer $TOKEN" ``` **Response** ```json theme={"dark"} { "data": [ { "id": "b1a2c3d4-e5f6-4789-abcd-1234567890ef", "name": "CI pipeline token", "kind": "agent", "prefix": "kp_live_a_a4", "dpop_required": false, "scope_jsonb": { "providers": ["anthropic"], "models": ["claude-sonnet-4-6"] }, "expires_at": "2026-08-05T12:00:00.000Z", "revoked_at": null, "last_used_at": "2026-07-06T09:15:00.000Z", "created_at": "2026-07-06T12:00:00.000Z" } ], "meta": { "total": 1, "page": 1, "per_page": 20, "total_pages": 1, "request_id": "0d5b2a9e-1f3c-4a7d-8e2b-6c9a1f4d7e35" } } ``` ## Mint a token ```http theme={"dark"} POST /v1/ai-gateway/agents/{agentId}/tokens ``` Mints a new phantom token for the agent and returns the plaintext once. Requires the `mint` capability on `ai_gateway`. **Request body** — all fields optional. | Field | Type | Description | | - | - | - | | `name` | string | Label for the token. Defaults to `key-`. | | `kind` | `agent` \| `read` \| `tool` \| `oneshot` | Token kind. Defaults to `agent`; an unrecognized value falls back to `agent`. | | `dpop_required` | boolean | Bind the token to a DPoP key. Defaults to `false`. | | `dpop_jkt` | string | Base64url SHA-256 JWK thumbprint of your DPoP public key. **Required when `dpop_required` is `true`.** | | `expires_in_seconds` | number | TTL in seconds, clamped to `[60, 7776000]` (1 minute to 90 days). Omit for the default of 30 days; there is no way to mint a non-expiring token. | The token's capability scope (providers and models) is derived automatically from the agent's `default_model`. It is not accepted from the request body. ```bash cURL theme={"dark"} curl -X POST https://api.knoxcall.com/v1/ai-gateway/agents/7c4d2e9f-1a3b-4c6d-8e0f-2a4b6c8d0e1f/tokens \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "CI pipeline token", "kind": "agent", "expires_in_seconds": 2592000 }' ``` ```python Python theme={"dark"} import os import requests TOKEN = os.environ["KNOXCALL_ACCESS_TOKEN"] # OAuth access token — see Authentication agent = "7c4d2e9f-1a3b-4c6d-8e0f-2a4b6c8d0e1f" resp = requests.post( f"https://api.knoxcall.com/v1/ai-gateway/agents/{agent}/tokens", headers={"Authorization": f"Bearer {TOKEN}"}, json={"name": "CI pipeline token", "expires_in_seconds": 2592000}, ) token = resp.json()["data"]["token"] # shown only once ``` **Response** ```json theme={"dark"} { "data": { "id": "b1a2c3d4-e5f6-4789-abcd-1234567890ef", "name": "CI pipeline token", "kind": "agent", "prefix": "kp_live_a_a4", "token": "kp_live_a_a4b2...", "dpop_required": false, "expires_at": "2026-08-05T12:00:00.000Z" }, "meta": { "request_id": "0d5b2a9e-1f3c-4a7d-8e2b-6c9a1f4d7e35", "note": "Save this token now — it will not be shown again." } } ``` `expires_at` is always set: omitting `expires_in_seconds` gives the 30 days default rather than a permanent token. (Tokens minted before this default existed can still carry a `null` `expires_at`.) If `dpop_required` is `true` but `dpop_jkt` is missing, the request returns `400 validation` — a DPoP-bound token is unusable without the key thumbprint. See [Tokens & DPoP](/ai-gateway/tokens-and-dpop) for generating the thumbprint and sending DPoP proofs. ## Revoke a token ```http theme={"dark"} DELETE /v1/ai-gateway/agents/{agentId}/tokens/{tokenId} ``` Immediately revokes the token. In-flight requests that have already passed auth complete; no new requests are accepted. Requires the `write` capability. Returns `404 not_found` if the token does not exist or is already revoked. ```bash theme={"dark"} curl -X DELETE \ https://api.knoxcall.com/v1/ai-gateway/agents/7c4d2e9f-1a3b-4c6d-8e0f-2a4b6c8d0e1f/tokens/b1a2c3d4-e5f6-4789-abcd-1234567890ef \ -H "Authorization: Bearer $TOKEN" ``` **Response** ```json theme={"dark"} { "data": { "id": "b1a2c3d4-e5f6-4789-abcd-1234567890ef", "revoked": true }, "meta": { "request_id": "0d5b2a9e-1f3c-4a7d-8e2b-6c9a1f4d7e35" } } ``` # List Phantom Tokens Source: https://docs.knoxcall.com/api-reference/ai-gateway/tokens-list GET /ai-gateway/agents/{agentId}/tokens One agent's phantom tokens. Never returns plaintext. Returns the agent's tokens, newest first, paginated. KnoxCall stores only a hash, so **no response ever contains a token's plaintext** — `prefix` is the first 12 characters, enough to identify a token in a list and safe to store. This endpoint lists tokens belonging to **this agent**. A token minted without an agent — the OIDC exchange produces these — is invisible here; find those with [List gateway tokens](/api-reference/ai-gateway/gateway-tokens-list). `expires_at` can be `null` on a token minted before 2026-08-30, when an omitted expiry meant "never". Those are worth auditing: nothing will retire them for you. Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # Mint Phantom Token Source: https://docs.knoxcall.com/api-reference/ai-gateway/tokens-mint POST /ai-gateway/agents/{agentId}/tokens Mint a short-lived capability token for an agent. The plaintext is returned exactly once. The raw token is returned **once**, in this response. KnoxCall stores only a hash — no later call can retrieve it. Save it to your secrets manager immediately. Tokens are `kp_`-prefixed since 2026-09-01 (`kp___…`) — see the [changelog](/ai-gateway/changelog#phantom-tokens-move-to-their-own-kp_-prefix); a token minted before that date no longer parses. Two behaviours worth knowing: * **Omitting `expires_in_seconds` gives 30 days, not "never".** Any requested TTL is clamped to between 60 seconds and 90 days. Before 2026-08-30 an omitted expiry meant the token never expired. * **The capability scope is empty, deliberately.** This endpoint offers no parameter to set one, and an earlier revision *guessed* a provider restriction from the model name — which mislabelled every Gemini, Mistral and Cohere agent and would have refused the token's own traffic. An empty scope means "no token-level restriction", which is the truth. See [Scoping](/ai-gateway/tokens-and-dpop#scoping) for what a scope enforces when one is present, and how to get one today. `dpop_required: true` requires `dpop_jkt` in the same request — a DPoP-bound token with no key thumbprint is unusable, so it is refused with `400 validation` rather than minted. **The dashboard twin.** The same operation exists on the admin host as `POST /admin/ai-gateway/{gatewayId}/agents/{agentId}/keys`, session-authenticated with an `X-Tenant-ID` header and an owner/admin role. That is what the dashboard itself calls. Prefer the `/v1` operation above for anything you write: it is the surface the five SDKs, the Postman collection and this spec cover. # Revoke Phantom Token Source: https://docs.knoxcall.com/api-reference/ai-gateway/tokens-revoke DELETE /ai-gateway/agents/{agentId}/tokens/{tokenId} Immediately revoke an agent phantom token. Revocation takes effect immediately: no new request is accepted. In-flight requests that have already passed authentication complete. Returns `404 not_found` if the token does not exist for this agent, or is already revoked. This endpoint addresses tokens **belonging to an agent**. A token minted without an agent is invisible here — revoke it with [Revoke a gateway token](/api-reference/ai-gateway/gateway-tokens-revoke). **The dashboard twin.** The same operation exists on the admin host as `DELETE /admin/ai-gateway/{gatewayId}/agents/{agentId}/keys/{tokenId}`, session-authenticated with an `X-Tenant-ID` header and an owner/admin role. That is what the dashboard itself calls. Prefer the `/v1` operation above for anything you write: it is the surface the five SDKs, the Postman collection and this spec cover. # Usage & Cost (v1) Source: https://docs.knoxcall.com/api-reference/ai-gateway/usage GET /ai-gateway/usage Read AI Gateway request counts, token counts, and estimated cost, grouped by model, over the public /v1 Management API. # Usage & cost Returns aggregated AI Gateway usage — request counts, input/output token counts, and estimated USD cost — broken down by provider and model, with tenant-wide totals. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication and the response envelope — the examples below authenticate with `$TOKEN`, a short-lived OAuth 2.1 access token minted there. ## Get usage ```http theme={"dark"} GET /v1/ai-gateway/usage ``` Requires the `read` capability on `ai_gateway`. **Query parameters** | Parameter | Type | Description | | - | - | - | | `period` | `7d` \| `30d` \| `90d` | Look-back window. Defaults to `30d`; any other value is treated as `30d`. | | `agent_id` | string (uuid) | Optional. Restrict the aggregation to a single agent. | ```bash cURL theme={"dark"} curl "https://api.knoxcall.com/v1/ai-gateway/usage?period=30d" \ -H "Authorization: Bearer $TOKEN" ``` ```python Python theme={"dark"} import os import requests TOKEN = os.environ["KNOXCALL_ACCESS_TOKEN"] # OAuth access token — see Authentication resp = requests.get( "https://api.knoxcall.com/v1/ai-gateway/usage", headers={"Authorization": f"Bearer {TOKEN}"}, params={"period": "30d"}, ) usage = resp.json()["data"] ``` ```javascript Node.js theme={"dark"} const resp = await fetch( "https://api.knoxcall.com/v1/ai-gateway/usage?period=30d", { headers: { Authorization: `Bearer ${process.env.KNOXCALL_ACCESS_TOKEN}` } } ); const { data: usage } = await resp.json(); ``` **Response** ```json theme={"dark"} { "data": { "period_days": 30, "by_model": [ { "provider": "anthropic", "model": "claude-sonnet-4-6", "requests": 1420, "input_tokens": 3120544, "output_tokens": 512310, "cost_usd": 42.87, "unpriced_requests": 0 }, { "provider": "openai", "model": "gpt-4o", "requests": 260, "input_tokens": 410233, "output_tokens": 88110, "cost_usd": 6.12, "unpriced_requests": 4 } ], "totals": { "requests": 1680, "input_tokens": 3530777, "output_tokens": 600420, "cost_usd": 48.99, "unpriced_requests": 4 } }, "meta": { "request_id": "0d5b2a9e-1f3c-4a7d-8e2b-6c9a1f4d7e35" } } ``` **Fields** | Field | Description | | - | - | | `period_days` | The resolved look-back window in days (`7`, `30`, or `90`). | | `by_model[]` | One row per `(provider, model)` seen in the window, ordered by descending cost. | | `by_model[].requests` | Number of proxied requests. | | `by_model[].input_tokens` / `output_tokens` | Summed token counts. | | `by_model[].cost_usd` | Estimated cost in USD. | | `by_model[].unpriced_requests` | Requests for which no price could be computed (model not in the price table); these contribute `0` to `cost_usd`. | | `totals` | The same metrics summed across every `by_model` row. | `cost_usd` is an estimate derived from KnoxCall's model price table. When `unpriced_requests` is non-zero, the true cost for those requests is higher than the reported `cost_usd`. # Export Usage (CSV) Source: https://docs.knoxcall.com/api-reference/ai-gateway/usage-export GET /ai-gateway/usage/export Aggregated AI Gateway spend as CSV, for showback and chargeback. Exports aggregated spend for a period as CSV, grouped by the dimension your finance process needs. `group_by` is a strict allowlist and a tag key is bound as a query parameter, so there is no injection surface. Every row carries `unpriced_requests` alongside `cost_usd`, so a pricing gap is visible in the export rather than hidden inside a total. A model that is not in the pricebook is recorded with `cost_source = 'unpriced'` — never booked as free. **Cells are quoted and formula-neutralized.** Every cell is quoted, and any cell beginning `=`, `+`, `-` or `@` is prefixed with a single quote, so a model name or a tag cannot execute as a formula when someone opens the file in a spreadsheet. If you parse this file, parse it as real CSV — do not split on commas. For the same data as JSON, use [Usage & cost](/api-reference/ai-gateway/usage). Requires the `read` capability on `ai_gateway`. See the [control-plane overview](/api-reference/ai-gateway/control-plane-overview#authentication) for authentication, the `{data, meta}` envelope, pagination and error types. # Create API Key Source: https://docs.knoxcall.com/api-reference/api-keys/create Create a new API key for programmatic access to your tenant ## Create API Key ```text theme={"dark"} POST /v1/api-keys ``` Creates a new API key. The full key value is returned **only once** in the response -- store it securely. For **new integrations** we recommend OAuth clients or the [SDK](/sdks/overview) instead of long-lived API keys — short-lived tokens, scope narrowing, and no long-lived secret to store. See the [Authentication guide](/api-reference/authentication). This endpoint remains fully supported; the examples below authenticate with a minted OAuth access token (`$TOKEN`). ### Request Body | Field | Type | Required | Description | | - | - | - | - | | `name` | string | Yes | A descriptive name (e.g., "CI/CD Pipeline", "Backend Server") | | `rate_limit_requests` | integer | No | Maximum requests per window for this key | | `rate_limit_window_sec` | integer | No | Rate limit window duration in seconds | | `role_ids` | array of uuid | No | Permission roles to attach, assigned in the **same transaction** as the key. Max 20. Only the **seeded** key roles (`Key — Invoke`, `Key — Read-only`, `Key — Editor`, `Key — Infrastructure`) are accepted: custom roles are being retired in favour of per-key permissions. See [List Roles](/api-reference/api-keys/roles). | The `key_type` is set automatically based on the host the request is sent to and **cannot** be specified in the request body. Requests to `api.knoxcall.com` produce a `standard` key; requests to `sandbox.knoxcall.com` produce a `test` key. ### Response ```json theme={"dark"} { "data": { "key_id": "tk_9f3a1c2e", "api_key": "tk_9f3a1c2e_4d2b8a1f0c9e7d6b5a4c3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e1", "key_prefix": "tk_9f3a1c2e_", "key_type": "standard", "name": "CI/CD Pipeline", "id": "f0a1b2c3-d4e5-6f7a-8b9c-0d1e2f3a4b5c", "role_ids": [], "message": "Store this API key securely. It will not be shown again." }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` The `api_key` field contains the full key and is **only returned at creation time**. KnoxCall does not store the plain-text key. If you lose it, you must revoke it and create a new one. ```bash cURL theme={"dark"} # $TOKEN is a minted OAuth access token — see /api-reference/authentication curl -X POST https://api.knoxcall.com/v1/api-keys \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "CI/CD Pipeline" }' ``` ```python Python theme={"dark"} import os import requests resp = requests.post( "https://api.knoxcall.com/v1/api-keys", headers={ "Authorization": f"Bearer {os.environ['KNOXCALL_ACCESS_TOKEN']}", "Content-Type": "application/json" }, json={"name": "CI/CD Pipeline"} ) new_key = resp.json()["data"] print(f"Store this key: {new_key['api_key']}") ``` ```javascript Node.js theme={"dark"} const resp = await fetch("https://api.knoxcall.com/v1/api-keys", { method: "POST", headers: { Authorization: `Bearer ${process.env.KNOXCALL_ACCESS_TOKEN}`, "Content-Type": "application/json" }, body: JSON.stringify({ name: "CI/CD Pipeline" }) }); const { data: newKey } = await resp.json(); console.log(`Store this key: ${newKey.api_key}`); ``` ### Permissions on the new key A key created with **no** `role_ids` holds no policy, and is therefore **default-denied on every policy-gated endpoint** — it authenticates, then 403s. That is the platform default since the legacy `*:*` auto-attach was retired, and it is almost never what you want. **Roles govern the `/v1` management API, not calls to your routes.** The proxy does not evaluate a key's roles today: any active key can call any enabled route in its mode (Live or Test) — including a key holding only `Key — Read-only`, or no role at all. What limits proxied calls is each route's own configuration: **Require authorized clients** (set per environment — the caller must be one of the route's clients, identified by source IP or mTLS certificate) and the route's **IP allowlist**. An OAuth access token minted with explicit scopes is narrowed on the proxy as well: it needs `routes:invoke:` for one route, or `routes:invoke`, `routes:*` or `*:*` for every route. Management scopes such as `routes:read` and `routes:write` do not let it call a route. List the roles you can attach with [`GET /v1/roles?subject_kind=api_key`](/api-reference/api-keys/roles) and pass their ids: ```bash theme={"dark"} curl -X POST https://api.knoxcall.com/v1/api-keys -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{ "name": "terraform-prod", "role_ids": ["b3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d"] }' ``` **A key can never mint a key more privileged than itself.** Every rule in every requested role must be covered by a rule the *calling* credential already holds. If it is not, the request is refused with `403 privilege_escalation` and the response names the offending grant verbatim so you can see exactly what was rejected: ```json theme={"dark"} { "error": { "type": "privilege_escalation", "message": "This API key cannot grant a permission it does not itself hold. Refused grant from role \"Key — Infrastructure\": {\"resource_type\":\"vault\",\"actions\":[\"create\"],\"effect\":\"allow\"} — no rule in your own policy set grants it.", "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` Two grants can never be delegated at all by a key holding only a wildcard (`*:*`) rule: `secret:reveal` and the custody / workload-binding actions. Delegating one requires the calling key to name that exact `(resource_type, action)` pair literally. **A scope-narrowed OAuth access token cannot create API keys**, with or without `role_ids`. An API key carries no scope narrowing, so any key such a token created would exceed the token's own grant; the call returns `403 privilege_escalation`. Create keys with an API key, or with an OAuth token whose scope is not narrowed (no scope requested, or `*`). ### Errors | Status | Type | Description | | - | - | - | | 400 | `validation_error` | Missing `name` field; `role_ids` is not an array of UUIDs; more than 20 role ids; or a requested role's audience is `user`, not `api_key` | | 403 | `privilege_escalation` | A requested role grants a permission the calling key does not itself hold (the message names the offending grant verbatim), or the call was made with a scope-narrowed OAuth token, which cannot create an API key at all | | 403 | `forbidden` | A requested role is not one of the seeded key roles. Custom roles are being retired: a key that already holds one keeps it, but no new key can be given one | | 404 | `not_found` | A `role_ids` entry does not exist in your tenant (another tenant's role id is indistinguishable from a nonexistent one, by design) | # List API Keys Source: https://docs.knoxcall.com/api-reference/api-keys/list Returns a paginated list of API keys for your tenant ## List API Keys ```text theme={"dark"} GET /v1/api-keys ``` Returns a paginated list of API keys for your tenant. Key values are **masked** -- only the prefix is shown. For **new integrations** we recommend OAuth clients or the [SDK](/sdks/overview) instead of long-lived API keys — see the [Authentication guide](/api-reference/authentication). This endpoint remains fully supported; the examples below authenticate with a minted OAuth access token (`$TOKEN`). Results are filtered by the host you call. The live host (`api.knoxcall.com`) returns only `standard` and `access_key` keys, while the sandbox host (`sandbox.knoxcall.com`) returns only `test` keys. ### Query Parameters | Parameter | Type | Default | Description | | - | - | - | - | | `page` | integer | `1` | Page number | | `per_page` | integer | `20` | Items per page (max 100) | ### Response ```json theme={"dark"} { "data": [ { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "key_id": "tk_a1b2c3d4", "key_prefix": "tk_a1b2c3d4_", "key_type": "standard", "name": "Backend Server", "active": true, "created_at": "2026-01-15T09:30:00.000Z", "last_used_at": "2026-03-10T14:22:01.000Z", "rate_limit_requests": null, "rate_limit_window_sec": null } ], "meta": { "total": 3, "page": 1, "per_page": 20, "total_pages": 1, "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ```bash cURL theme={"dark"} # $TOKEN is a minted OAuth access token — see /api-reference/authentication curl https://api.knoxcall.com/v1/api-keys \ -H "Authorization: Bearer $TOKEN" ``` ```python Python theme={"dark"} import os import requests resp = requests.get( "https://api.knoxcall.com/v1/api-keys", headers={"Authorization": f"Bearer {os.environ['KNOXCALL_ACCESS_TOKEN']}"} ) keys = resp.json()["data"] ``` ```javascript Node.js theme={"dark"} const resp = await fetch("https://api.knoxcall.com/v1/api-keys", { headers: { Authorization: `Bearer ${process.env.KNOXCALL_ACCESS_TOKEN}` } }); const { data: keys } = await resp.json(); ``` # Revoke API Key Source: https://docs.knoxcall.com/api-reference/api-keys/revoke Permanently revoke an API key, disabling all further access ## Revoke API Key ```text theme={"dark"} DELETE /v1/api-keys/:id ``` Revokes an API key, permanently disabling it. Revoked keys cannot be reactivated. For **new integrations** we recommend OAuth clients or the [SDK](/sdks/overview) instead of long-lived API keys — see the [Authentication guide](/api-reference/authentication). This endpoint remains fully supported. ### Path Parameters | Parameter | Type | Description | | - | - | - | | `id` | string | The API key ID (UUID or `key_id`) | ### Response ```json theme={"dark"} { "data": { "revoked": true }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` You cannot revoke the API key that you are currently using to authenticate the request. Use a different key to revoke it, or revoke it from the dashboard. ### Errors | Status | Type | Description | | - | - | - | | 400 | `validation_error` | Cannot revoke the key currently in use | | 404 | `not_found` | API key not found | # List Roles Source: https://docs.knoxcall.com/api-reference/api-keys/roles Discover the permission roles you can attach to an API key ## List Roles ```text theme={"dark"} GET /v1/roles ``` Returns your tenant's permission roles. This is the endpoint that makes [`role_ids`](/api-reference/api-keys/create) usable from code: a Terraform module or a provisioning script cannot hard-code a per-tenant UUID, and before this existed the only way to find one was to open the admin UI and copy it out of the URL bar. **Read-only, permanently.** There is no `POST`, `PATCH` or `DELETE` on `/v1/roles`. Only the **seeded** key roles (`seeded: true`) are accepted in [`role_ids`](/api-reference/api-keys/create). Custom roles are being retired in favour of per-key permissions: no new one can be created, and this list still returns any your tenant already has (`seeded: false`) — `POST /v1/api-keys` refuses those with `403 forbidden`. Filter on `seeded` when choosing a role. Requires the `role:list` permission, which all four seeded machine roles carry. ### Query Parameters | Parameter | Type | Description | | - | - | - | | `subject_kind` | string | `api_key` or `user`. Returns only roles whose `applies_to` includes it. With `api_key`, the rows you can attach to a key are the ones with `seeded: true`. Any other value is a `400`. | | `page` | integer | Page number (default `1`) | | `per_page` | integer | Results per page (default `20`, max `100`) | ### Response ```json theme={"dark"} { "data": [ { "id": "b3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "name": "Key — Infrastructure", "description": "For Terraform and other infrastructure-as-code runners.", "applies_to": ["api_key"], "is_default": false, "seeded": true }, { "id": "c4e2d3f5-6a7b-4c8d-9e0f-1a2b3c4d5e6f", "name": "Key — Read-only", "description": "List/read config and analytics/log metadata.", "applies_to": ["api_key"], "is_default": true, "seeded": true } ], "meta": { "total": 2, "page": 1, "per_page": 20, "total_pages": 1, "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` | Field | Description | | - | - | | `applies_to` | Subject kinds the role may be assigned to. Passing a role whose `applies_to` is `["user"]` in `role_ids` returns a `400`. | | `is_default` | Whether the admin UI prefills this role when creating a key. | | `seeded` | `true` for roles KnoxCall creates and maintains. Seeded roles cannot be deleted, renamed or recomposed. Only a seeded key role is accepted in `role_ids`; `false` marks a custom role, which is being retired. | The **rules** a role grants are deliberately not returned. Enumerating them would hand every machine credential in the tenant a map of your authorization surface. To see what a role grants, open it in the admin UI. ### The seeded machine roles | Role | For | | - | - | | `Key — Invoke` | Intended for keys that **call** your APIs: reads the route/client/environment config needed to resolve a target, and runs workflows. No writes. Holding it is not what lets a key call a route — see the warning below. | | `Key — Read-only` | List/read config and analytics metadata. The default for a fresh key. It does not stop the key calling your routes — see the warning below. | | `Key — Editor` | CRUD on routes, clients, environments, webhooks and secrets. No key management, no billing, no permissions. | | `Key — Infrastructure` | **Terraform and other infrastructure-as-code runners.** The whole provisioning surface: routes, environments, secrets, clients, API keys, webhooks, workflows, vaults, PKI, dynamic DB credentials, transit keys, AI gateways, OAuth clients and agents. | **No role restricts which routes a key can call.** Roles are enforced on the `/v1` management API. The proxy does not evaluate them: any active key can call any enabled route in its mode (Live or Test), whatever roles it holds — so `Key — Invoke` and `Key — Read-only` behave identically on proxied calls today. Contain proxied use with each route's **Require authorized clients** setting and **IP allowlist**, and with `routes:invoke` scopes on OAuth access tokens, which the proxy does enforce. `Key — Infrastructure` deliberately **excludes**, and explicitly denies: * `secret:reveal` — a provisioning credential never needs the plaintext of a secret it created. KnoxCall holds the plaintext; that is the product. * `secret:custody_enable` / `custody_disable` / `custody_rotate` — custody transitions decrypt the current administrative credential server-side. * `workload_binding:create` / `delete` — a binding is a permanent route to a live credential for anyone presenting a matching JWT. * `ephemeral_proxy:invoke` — one-shot runtime invocation against live credentials. It is not infrastructure state, and no Terraform resource models it. None of those four can be reached by a wildcard rule either: they require an exact `(resource_type, action)` allow, so a legacy `*:*` key does not silently hold them. **Excluding `secret:reveal` does not make an Infrastructure key plaintext-free.** It still holds operations that return plaintext or mint live credentials by design — `transit:decrypt`, `vault:detokenize`, `secret:oauth_token_get`, `pki:issue` (the leaf certificate's private key), `dyn_db_cred:mint` and `ai_gateway:mint` — and it can create and update routes and workflows, which inject your secrets into the requests they send. Re-pointing one at another host sends the secret there. Treat an Infrastructure key as able to reach your secrets, and store it accordingly. ### Examples ```bash cURL theme={"dark"} # $TOKEN is a minted OAuth access token — see /api-reference/authentication curl "https://api.knoxcall.com/v1/roles?subject_kind=api_key" \ -H "Authorization: Bearer $TOKEN" ``` ```python Python theme={"dark"} from knoxcall import KnoxCall kc = KnoxCall(tenant="acme") roles = kc.roles.list(subject_kind="api_key")["data"] infra = next(r for r in roles if r["name"] == "Key — Infrastructure") key = kc.api_keys.create(name="terraform-prod", role_ids=[infra["id"]]) print(key["api_key"]) # shown once ``` ```javascript Node.js theme={"dark"} import { KnoxCall } from "@knoxcall/sdk"; const kc = new KnoxCall({ tenant: "acme" }); const { data: roles } = await kc.roles.list({ subject_kind: "api_key" }); const infra = roles.find((r) => r.name === "Key — Infrastructure")!; const key = await kc.apiKeys.create({ name: "terraform-prod", role_ids: [infra.id] }); console.log(key.api_key); // shown once ``` ### Errors | Status | Type | Description | | - | - | - | | 400 | `validation_error` | `subject_kind` was neither `api_key` nor `user` | | 403 | `forbidden` | The calling key does not hold `role:list` | # SHA-256 Hash Chain — Critical Audit Chain Source: https://docs.knoxcall.com/api-reference/audit-logs/chain Tamper-evident append-only audit chain for sensitive operations including BYOK KMS lifecycle, secret store migrations, and tenant impersonation ## SHA-256 Hash Chain — Critical Audit Chain For sensitive operations — BYOK KMS lifecycle, secret store migrations, and tenant impersonation — each write goes into a **separate, append-only table** (`audit_log_critical`) that is distinct from the standard `audit_logs` table. The critical chain is protected by: * **Application-layer SHA-256 hash chain** — each row commits to the prior row via `prev_hash` (32 bytes) and `row_hash = SHA-256(canonical_bytes_of_this_row)`. Tampering with any row changes its `row_hash`, which breaks every subsequent `prev_hash` link. A verifier can detect the exact row that was altered. * **Append-only enforcement** — database triggers prevent UPDATE and DELETE at the Postgres level. There is no retention pruning on this table; records exist indefinitely. * **Serialized writes** — an advisory lock (`pg_advisory_xact_lock`) on every write ensures monotonic, gap-free `sequence_number` values. A gap in sequence numbers indicates a deletion attempt. ### Critical chain row schema | Field | Type | Description | | - | - | - | | `sequence_number` | BIGINT | Monotonically increasing; gaps indicate tampering | | `prev_hash` | BYTEA (32) | SHA-256 of the previous row's canonical bytes; all-zero for the genesis row | | `row_hash` | BYTEA (32) | SHA-256 of this row's canonical bytes | | `at` | TIMESTAMPTZ | Wall-clock timestamp of the action | | `tenant_id` | UUID | Tenant subject of the action (nullable for operator-level events) | | `actor_id` | UUID | User who performed the action (nullable for system-triggered events) | | `action` | TEXT | One of the 15 action values listed above | | `resource_type` | TEXT | One of the 7 resource types listed above | | `resource_id` | TEXT | UUID or other identifier of the affected resource | | `details` | JSONB | Action-specific context. Never contains plaintext secret values. | ### Canonical serialization The `row_hash` is computed over a deterministic length-prefixed byte sequence: ```text theme={"dark"} sequence_number (8 bytes, big-endian) || prev_hash (32 bytes, verbatim) || len(at_iso) + at_iso + sentinel || len(tenant_id) + tenant_id + sentinel || len(actor_id) + actor_id + sentinel || len(action) + action + sentinel || len(resource_type) + resource_type + sentinel || len(resource_id) + resource_id + sentinel || len(details_canonical_json) + details_canonical_json + sentinel ``` Where `details_canonical_json` is JSON with keys recursively sorted so the hash is deterministic regardless of insertion order. The sentinel byte distinguishes `null` (`\x00`) from empty string (`\x01`). The authoritative implementation is `computeRowHash()` in `src/audit/critical.ts`. An external verifier can re-derive any row's hash from raw DB values and this spec. ### Verifying the chains The critical chain (`audit_log_critical`, described above) is verified by `verifyChainSegment()` in `src/audit/critical.ts`, which walks the chain in `sequence_number` order, recomputes each `row_hash`, and stops at the first break. It runs nightly via the `verify-audit-chain` cron job. There is currently **no HTTP endpoint** that exposes the critical-chain verifier. A separate verifier covers the standard `audit_logs` table. It is exposed through the dashboard admin API: ```bash theme={"dark"} # Verify the current tenant's standard audit_logs hash chain. # /admin/* lives on the dashboard host (admin.knoxcall.com / any # knoxcall.com host), gated by your admin session JWT + X-Tenant-ID. curl https://admin.knoxcall.com/admin/audit-logs/verify-chain \ -H "Authorization: Bearer $KC_SESSION_JWT" \ -H "X-Tenant-ID: $KC_TENANT_ID" ``` The endpoint scans the entire tenant chain (every `audit_logs` row with a `row_hash`, ordered by the monotonic `seq` column) and returns the verification result verbatim: ```json theme={"dark"} { "tenantId": "00000000-0000-0000-0000-000000000000", "status": "verified", "rowsChecked": 142, "legacyRows": 0, "unhashedRows": 0, "chainTriggerArmed": true, "tampered": 0, "brokenChainAt": [], "invalidHashAt": [], "valid": true } ``` `status` is the field to read; `valid` is simply `status === "verified"`. | `status` | Meaning | | - | - | | `verified` | At least one row was checked and every check passed. | | `invalid` | A stored `row_hash` or a chain link did not reproduce. The offending row `id`s are listed. | | `unverifiable` | The answer carries no assurance — **not** the same as clean. `unverifiableReason` says which: `trigger_not_armed` (the writer trigger is missing or disabled, so new rows are landing outside the chain), `unhashed_rows` (rows exist that the chain does not cover), or `no_rows` (the verified segment was empty). | `legacyRows` counts rows written before the chain-ordering fix (migration 20260819); they are reported, not verified, until an operator re-chains history. A failed verification sets `status` to `invalid` and lists the offending row `id`s — `brokenChainAt` for rows whose `prev_hash` does not match the prior row's `row_hash`, and `invalidHashAt` for rows whose stored `row_hash` does not match the recomputed value: ```json theme={"dark"} { "tenantId": "00000000-0000-0000-0000-000000000000", "status": "invalid", "rowsChecked": 142, "legacyRows": 0, "unhashedRows": 0, "chainTriggerArmed": true, "tampered": 1, "brokenChainAt": [], "invalidHashAt": ["9f1c2b64-6d0a-4a3e-8b21-0d7a5f3c9e10"], "valid": false } ``` Note that this endpoint verifies the standard `audit_logs` chain, whose `row_hash` is the hex SHA-256 of the UTF-8 bytes of ```text theme={"dark"} prev_hash + tenant_id + COALESCE(user_id, actor_user_id_snapshot) + action + resource_type + resource_id + details::text + ip_address ``` — a plain string concatenation with each term replaced by the empty string when null, distinct from the length-prefixed canonical serialization of the critical chain above. `details::text` is Postgres's own jsonb rendering, not `JSON.stringify`. `actor_user_id_snapshot` is a copy of the acting user's id kept outside foreign-key control: `audit_logs.user_id` is `ON DELETE SET NULL`, so without it, deleting a user would rewrite a column their rows had already been hashed over and every one of them would report as tampered. The endpoint takes no range parameters; it always scans the whole tenant chain. # List Audit Logs Source: https://docs.knoxcall.com/api-reference/audit-logs/list Returns a paginated list of audit log entries for your tenant configuration changes ## List Audit Logs ```text theme={"dark"} GET /v1/audit-logs ``` Returns a paginated list of audit log entries, newest first. You can filter by action type and resource type. ### Who sees `details` `details` records what changed: before and after values, names, and addresses. It is always **sanitised**. Values under credential-shaped keys (`client_secret`, `access_token`, and so on) read `***REDACTED***`, and `user:password@` userinfo is removed from any URL. Identifiers are kept, such as the acting key's `api_key_id`. | Caller | `details` | | - | - | | API key or OAuth client with `audit_log:list` | Sanitised `details`, `details_redacted: false` | | User-bound token (the `knoxcall` CLI, device or authorization-code grant) whose holder is an **Admin or Owner** | Sanitised `details`, `details_redacted: false` | | User-bound token whose holder is a Viewer or Editor | `details: {}` and `details_redacted: true` | The rest of the row (action, resource, IP address and time) is returned to every caller. A redacted `details` is always an empty object, never a string, so typed clients decode it unchanged. `details` itself is always an object or `null`: an entry whose stored details are not a JSON object (a bare string, array or number from a self-hosted agent) is returned sanitised and wrapped as `{"value": ...}`. The same rule applies to `GET /v1/audit-logs/events` and to the dashboard's Audit Trail. ### Query Parameters | Parameter | Type | Default | Description | | - | - | - | - | | `page` | integer | `1` | Page number | | `per_page` | integer | `20` | Items per page (max 100) | | `action` | string | -- | Filter by action (e.g., `create`, `update`, `delete`) | | `resource_type` | string | -- | Filter by resource type (e.g., `route`, `secret`, `client`, `webhook`, `environment`, `api_key`) | ### Response ```json theme={"dark"} { "data": [ { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "action": "create", "resource_type": "route", "resource_id": "rt-uuid-1", "details": { "name": "Payment Gateway", "target_base_url": "***REDACTED***" }, "details_redacted": false, "ip_address": "203.0.113.42", "created_at": "2026-03-10T14:22:01.000Z" }, { "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "action": "update", "resource_type": "secret", "resource_id": "sec-uuid-1", "details": { "field": "value", "environment": "production" }, "details_redacted": false, "ip_address": "203.0.113.42", "created_at": "2026-03-10T14:20:00.000Z" } ], "meta": { "total": 487, "page": 1, "per_page": 20, "total_pages": 25, "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ```typescript Node.js theme={"dark"} import { KnoxCall } from "@knoxcall/sdk"; const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET // List all audit logs const page = await client.auditLogs.list(); const logs = page.data; // Filter by action and resource type const filtered = await client.auditLogs.list({ action: "delete", resource_type: "route" }); const deletions = filtered.data; ``` ```python Python theme={"dark"} from knoxcall import KnoxCall client = KnoxCall() # credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET # List all audit logs page = client.audit_logs.list() logs = page["data"] # Filter by resource type filtered = client.audit_logs.list(resource_type="secret", per_page=50) secret_logs = filtered["data"] ``` ```bash cURL theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) # List all audit logs curl "https://api.knoxcall.com/v1/audit-logs" \ -H "Authorization: Bearer $TOKEN" # Filter by action and resource type curl "https://api.knoxcall.com/v1/audit-logs?action=delete&resource_type=route" \ -H "Authorization: Bearer $TOKEN" ``` ### Common Action Types Standard CRUD actions appear on any resource type: | Action | Description | | - | - | | `create` | A resource was created | | `update` | A resource was modified | | `delete` | A resource was deleted | | `enable` | A resource was enabled | | `disable` | A resource was disabled | | `revoke` | An API key was revoked | Critical audit chain actions — these go into the separate `audit_log_critical` table (see below): | Action | Description | | - | - | | `byok.kms.onboard` | Customer KMS provider onboarded (BYOK) | | `byok.kms.rotate` | Tenant master key rotated | | `byok.kms.unseal` | Tenant unsealed after KMS access restored | | `byok.kms.revoke` | Tenant master key version explicitly revoked (cryptographic erasure) | | `migration.create` | Secret store migration created | | `migration.cancel` | Migration cancelled | | `migration.item.reveal` | Reviewer revealed plaintext value of a migration item | | `migration.item.approve` | Reviewer approved a migration item | | `migration.item.reject` | Reviewer rejected a migration item | | `migration.item.expire` | Migration item TTL expired (cron-driven) | | `migration.commit` | Operator committed the migration as complete | | `migration.credentials.expired` | Migration provider credentials expired | | `migration.db_proxy_route.create` | DB proxy route created for a migration | | `migration.db_proxy_route.delete` | DB proxy route deleted | | `tenant.impersonate` | KnoxCall support admin impersonated a tenant | ### Common Resource Types General audit log resource types: | Resource Type | Description | | - | - | | `route` | API proxy route | | `secret` | Encrypted secret | | `client` | Authorized client | | `webhook` | Webhook configuration | | `environment` | Environment definition | | `api_key` | API key | | `environment_config` | Route environment override | Critical audit chain resource types (see `audit_log_critical` below): | Resource Type | Description | | - | - | | `tenant_kms_config` | BYOK KMS configuration | | `tenant_master_key` | Tenant master key version | | `secret_migration` | Secret store migration job | | `secret_migration_item` | Individual item within a migration | | `migration_db_proxy_route` | DB proxy route record for a migration | | `migration_credential` | Provider credential record for a migration | | `tenant_impersonation_session` | Admin impersonation session | Audit log entries are immutable and cannot be modified, but they are pruned according to your subscription plan's data retention policy — a scheduled cleanup deletes `audit_logs` rows older than the plan's retention window. The separate critical audit chain (`audit_log_critical`, see `chain` below) is never pruned and is retained permanently. # Authentication Source: https://docs.knoxcall.com/api-reference/authentication Authenticate with the KnoxCall API — SDK and OAuth 2.1 + DPoP first, with long-lived API keys as a supported legacy alternative KnoxCall supports two authentication paths, both fully supported, both safe to mix: 1. **OAuth 2.1 short-lived access tokens** (`kc_live_…`, `kc_test_…`) — minted via `POST /oauth/token`, expire in 1 hour, optionally sender-constrained with DPoP (RFC 9449). This is what the SDKs use under the hood, and the recommended path for new integrations. 2. **Long-lived API keys** (`tk_live_…`, `tk_test_…`, `AKE…`) — the original path, still works everywhere; treat it as the legacy alternative. If you've used Auth0, Okta, AWS Cognito, Stripe Connect, or any modern OAuth provider, our OAuth surface will feel identical — every endpoint conforms to a published IETF RFC. ## Quick start — OAuth 2.1 ```bash theme={"dark"} # Mint a 1-hour token from your existing API key TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "tk_abcd1234:rest_of_key" \ -d "grant_type=client_credentials" \ | jq -r .access_token) # Use it like any Bearer token curl -H "Authorization: Bearer $TOKEN" https://api.knoxcall.com/v1/routes ``` Your existing `tk_…` API key becomes the OAuth `client_id` + `client_secret`: * `client_id` = `tk_abcd1234` (everything before the second underscore) * `client_secret` = the remainder The OAuth path adds: * **1-hour TTL** instead of long-lived * **Scope narrowing** — request a subset of your key's permissions per call * **DPoP sender-constraint** (optional) — proof-of-possession of a keypair required per request ### OAuth clients that are not API keys A client created on the **OAuth Clients** page (or via `POST /v1/oauth-clients`) has no API key behind it. Its `client_credentials` tokens call `/v1` with whatever **roles** the client holds — assign them when you create the client, or later on its **Permissions** tab. A client with no role is denied on every `/v1` endpoint, and its `allowed_scopes` narrow each token further; a token can never do more than both its scope and the client's roles allow. A token issued to a *user* (authorization-code and device flows, including `knoxcall login`) calls `/v1` as that **user**: their own roles in the workspace, narrowed by the token's scope. Whatever roles or custom policies the user holds, a user-bound token: * **needs an enrolled second factor** to act on the Live host; * **never performs a human-plane action** — revealing a secret's plaintext, managing permissions, changing custody. Those need a signed-in session with MFA and step-up, not a bearer token; * **never uses a reveal-class permission** — decrypting (`transit:decrypt`), detokenizing a vault value, minting a database credential, issuing a certificate that comes back with its private key (from the PKI engine, or a client's mTLS certificate with `mode: issue` — registering a certificate you generated yourself by its `sha256_thumbprint` still works), or fetching a live upstream OAuth2 token — **and never gives one to a machine credential**: it cannot attach a role carrying one to a new API key, or rotate the secret of an OAuth client that holds one. Automation that needs these uses an API key or OAuth client that an owner or admin grants the permission in the dashboard; * **uses owner and admin capabilities only for an owner or admin** of the workspace, as in the dashboard: listing, minting and revoking API keys; creating, changing and deleting OAuth apps; registering agents; changing AI gateways and minting their tokens; managing transit keys and rewrapping or signing with them (on `/v1`, managing includes listing keys and reading a key's metadata, which the dashboard shows to any member); managing PKI and database credentials; vaults; webhook test sends; and the ephemeral proxy; * **needs an edit permission, not only a read one, to mint or revoke a wrap token or to report egress observations** — `secret:update` for `POST` / `DELETE /v1/wrap/tokens`, `route:create` for `POST /v1/wrap/egress-observations`. An API key keeps doing these on read alone. These are the only bounds a user-bound token adds to the user's roles. A custom role or policy is part of a user's roles only when it contains a `deny` rule (or any rule that is not a well-formed `allow`). One made only of `allow` rules is no longer held by any person (see the 2026-10-08 entry in the [changelog](/api-reference/changelog)), and a custom role can no longer be assigned to a person. One that contains a `deny` rule stays on the person who holds it, and on `/v1` both its denies and its allows still apply. API keys and OAuth clients are not affected. ## OAuth 2.1 endpoints | Endpoint | Purpose | RFC | | - | - | - | | `POST /oauth/token` | Mint access + refresh tokens | RFC 6749 | | `POST /oauth/device_authorization` | Start a device-code flow (headless machines) | RFC 8628 | | `POST /oauth/introspect` | Validate a token (relying-party use) | RFC 7662 | | `POST /oauth/revoke` | Revoke a token | RFC 7009 | | `GET /.well-known/oauth-authorization-server` | Discovery | RFC 8414 | Supported grant types: * `client_credentials` — server-to-server, the simplest path * `authorization_code` + PKCE — human consent flow (with refresh tokens). Public clients may register loopback redirect URIs per RFC 8252 — `http://127.0.0.1`, `http://[::1]`, and `http://localhost` match on any port at authorization time, so native and CLI apps can receive the callback locally. * `urn:ietf:params:oauth:grant-type:device_code` — RFC 8628 device-code flow for headless/SSH machines: `POST /oauth/device_authorization` returns a user code, the user approves it at `/oauth/activate` on any browser, and the device polls the token endpoint. This is what `knoxcall login --device` uses. * `refresh_token` — single-use rotation with family-detection theft protection * `urn:ietf:params:oauth:grant-type:token-exchange` — workload identity federation. **Any OIDC issuer works**; the SDKs additionally auto-detect several platforms for you (see below) ## DPoP — sender-constrained tokens (RFC 9449) For tenants requiring the highest security tier, request DPoP-bound tokens. Send a DPoP proof JWT on the token request; the issued token is bound to your keypair via the `cnf.jkt` claim. Every subsequent API call must carry a fresh DPoP proof signed by the same key. ```typescript theme={"dark"} // Use the SDK — DPoP is transparent import { KnoxCall } from "@knoxcall/sdk"; const client = new KnoxCall({ tenant: "acme", dpop: "always" }); ``` ## Workload identity federation (RFC 8693) Running on a platform that issues its workloads an OIDC token? **You don't need an API key at all.** Register a binding, and the exchange trades that token for a short-lived KnoxCall access token. **Any OIDC issuer is accepted** — if it publishes `/.well-known/openid-configuration` over https and signs with any asymmetric algorithm (the full `RS`/`PS`/`ES` family plus `EdDSA` — see [What your issuer's token must carry](#what-your-issuer-s-token-must-carry) below), you can federate from it by registering the issuer and the claims that identify your workload. **Auto-detection is a convenience on top of that.** Where the SDK recognises the platform it fetches the token for you with no configuration: GitHub Actions, Google Cloud, AWS (IRSA), Azure managed identity, Vercel, CircleCI and Buildkite. **Coverage differs by language** — the Node SDK covers all seven, the Python SDK covers five (no Azure, no Buildkite). On any other platform, or from an SDK that does not detect yours, fetch the token yourself and call the exchange directly; HCP Terraform works this way today. **Which claims identify your workload depends on the issuer.** KnoxCall carries a claim table for three issuers — GitHub Actions (`repository`, `job_workflow_ref` or `sub`), HCP Terraform (`sub`) and Google (`email` or `sub`) — and requires your binding to pin at least one of them. Every other issuer falls back to `sub` alone, which works but is a blunter instrument: `sub` is the only claim OIDC guarantees identifies a subject. This matters because a binding that pins no identifying claim would describe a *population* rather than a workload — on a shared issuer like GitHub's, `{"ref": "refs/heads/main"}` is true for every repository on GitHub — so the API refuses one with `400`. ```yaml theme={"dark"} # GitHub Actions example — zero stored secrets - run: | npm install @knoxcall/sdk@^1.0.0 node my-script.js env: KNOXCALL_TENANT: acme permissions: id-token: write ``` Configure the trust binding once in the KnoxCall dashboard under **Settings → Workload Identity**. Subsequent CI runs auto-authenticate. ### What your issuer's token must carry These are checked on every exchange, on both `POST /oauth/token` and `POST /v1/oauth/token`. Every mainstream IdP satisfies them by default; they are listed because a refusal that names a missing claim is easier to fix than one that does not. | Requirement | Why | | - | - | | **A `kid` in the JWS header** | It names which of the issuer's published keys signed the token. Required even when the issuer publishes only one key — otherwise the rule would switch itself off for the length of a key rotation, which is precisely when key selection needs to be unambiguous. | | **An `exp` claim** | It is the only claim that bounds how long a captured assertion stays usable. A token with no `exp` is refused rather than treated as never expiring. | | **An `iss` matching a registered binding** | The issuer URL is compared to your binding exactly, as a string. | | **An asymmetric `alg`** | One of `RS256/384/512`, `PS256/384/512`, `ES256/384/512`, `EdDSA`. HMAC algorithms (`HS*`) and `none` are always refused — the only key material we hold for your issuer is its *public* JWKS, so accepting an HMAC would mean verifying a token signed with the key we ourselves published. | Assertions are **single use**. Each one is spent by the exchange that accepts it, so a long-running job must mint a fresh token from its platform before each exchange rather than reusing the first — the SDKs' `WorkloadCredentialProvider` does this for you. ## Create an OAuth client An OAuth client is how an SDK or a script authenticates in CI and on servers. (On a developer machine you can [log in once with the CLI](#log-in-once-with-the-cli) instead.) You create it once in the dashboard: 1. Switch the dashboard to the mode you want to call, **Live** or **Test**. A client belongs to the mode it was created in. 2. Open **Settings** → **API** → **OAuth Clients** and click **Create OAuth Client**. 3. Enter a **Name** (for example `my-laptop`) and keep the type **Server-to-server**. 4. Under **Allowed scopes**, list the scopes the client needs, separated by spaces. For example, `routes:read routes:invoke` lets it list your routes and call them through the proxy; add `routes:write` or `secrets:write` if it will create them. An empty list is refused. 5. Under **Permissions**, keep **Key — Read-only** (selected by default), or choose **Key — Editor** if the client will create or change routes, secrets or clients. A client with no role is refused on every `/v1` endpoint. 6. Click **Create Client**, then copy the **Client ID** and **Client secret**. The secret is shown once. Put the pair in the environment. Every SDK reads it with no code change: ```bash theme={"dark"} export KNOXCALL_CLIENT_ID="tk_xxxxxxxx" # the Client ID export KNOXCALL_CLIENT_SECRET="..." # the Client secret ``` A client created in Test mode calls the Test environment, so also pass the SDK's `sandbox` option (`sandbox: true` in Node and Ruby, `sandbox=True` in Python, `Sandbox: true` in Go, `'sandbox' => true` in PHP). ## Using the SDK (recommended) KnoxCall ships first-party SDKs for Node.js/TypeScript, Python, Go, PHP, and Ruby (plus a Terraform provider and browser/React Elements) — see the [SDKs overview](/sdks/overview) for the full family. Every SDK handles token minting, caching, retries, idempotency keys, and DPoP for you. ```bash Node.js / TypeScript — install theme={"dark"} npm install @knoxcall/sdk@^1.0.0 ``` ```typescript Node.js / TypeScript theme={"dark"} import { KnoxCall } from "@knoxcall/sdk"; const client = new KnoxCall({ tenant: "acme" }); const routes = await client.routes.list(); ``` ```python Python theme={"dark"} from knoxcall import KnoxCall # Credentials from KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET client = KnoxCall() page = client.routes.list() routes = page["data"] ``` ```go Go theme={"dark"} client, err := knoxcall.New(knoxcall.Options{}) if err != nil { log.Fatal(err) } page, err := client.Routes.List(context.Background(), nil) // page.Data holds the routes ``` ```php PHP theme={"dark"} $client = new KnoxCall(); // env-configured $page = $client->routes->list(); $routes = $page['data']; ``` ```ruby Ruby theme={"dark"} client = KnoxCall::Client.new # env-configured page = client.routes.list routes = page["data"] ``` ```bash cURL (raw REST) theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see Quick start above TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) curl https://api.knoxcall.com/v1/routes \ -H "Authorization: Bearer $TOKEN" ``` Every SDK handles credential auto-detection, token caching, refresh, retries, and DPoP signing — your code just calls methods. Prefer raw REST? Mint a short-lived OAuth token and pass it as `Authorization: Bearer ` (a legacy `tk_…` key in the same header also works) and parse the JSON response. ## Log in once with the CLI On a developer machine, skip keys and env vars entirely. Every KnoxCall SDK package ships the `knoxcall` command (pip, npm, gem, Composer, `go install` — same surface, same credentials file); it runs the authorization-code + PKCE flow in your browser (or the RFC 8628 device-code flow with `--device`) and stores the credential in `~/.knoxcall/credentials.json`: ```bash theme={"dark"} pip install "knoxcall>=1.0,<2.0" # or your ecosystem's equivalent — every SDK ships the CLI knoxcall login # opens your browser (PKCE); prints the URL too knoxcall login --device # headless/SSH machines: device-code flow knoxcall whoami # show the signed-in tenant knoxcall logout # revoke + remove the stored credential ``` Your browser opens the KnoxCall dashboard. Sign in if you are not already, check what is being authorized, choose the workspace and click **Approve** — the terminal finishes on its own. With `--device`, open the link the terminal prints on any device, confirm the code it shows, and approve there. Approving needs an account with a passkey, security key or authenticator app set up. `knoxcall login` signs you in to **Live**. To call the Test environment, use an [OAuth client](#create-an-oauth-client) created in Test mode and the SDK's `sandbox` option. Every KnoxCall SDK — Node.js, Python, Go, PHP, and Ruby — picks the file up automatically, so a zero-argument constructor just works: ```python theme={"dark"} from knoxcall import KnoxCall client = KnoxCall() # tenant and base URL come from the login ``` Access tokens refresh themselves; refreshes are cross-process safe (file lock + atomic rotation of the single-use refresh token). Behind the scenes, `knoxcall login` uses the reserved `knoxcall-cli` client alias, which provisions a per-tenant system OAuth client on first login — you'll see it on the OAuth Clients page, where it is visible but protected. See the [SDKs overview](/sdks/overview#log-in-once-with-the-cli) for profiles (`KNOXCALL_PROFILE`) and the full credential-resolution order. ## Postman / Insomnia / Bruno Download the official collection and import it into Postman. It uses Postman's native OAuth 2.0 support — sign in once via the **Authorization** tab, and every saved request inherits the token. For other tools, point them at the OAuth 2.0 discovery URL: `https://api.knoxcall.com/.well-known/oauth-authorization-server`. *** ## API Key Types KnoxCall uses API keys to authenticate requests to the Management API. There are three types of keys, each with a distinct prefix: | Key Type | Prefix | Environment | Description | | - | - | - | - | | Standard | `tk_live_` | Production | A live key. On the management API it can do only what its attached [roles](/api-reference/api-keys/roles) allow — with no role it is refused on every policy-gated `/v1` endpoint, and can reach only the API root and the two endpoints that describe the key itself (`GET /v1/account`, `GET /v1/account/usage`). Available on all plans. | | Enterprise Access | `AKE` | Production | A live access-key pair (access key ID + secret access key). Enterprise plan only. It is created with no roles and the default 90-day expiry; neither scopes nor expiry are chosen when you create it. | | Test | `tk_test_` | Sandbox | A sandbox key, authorized by its roles exactly like a standard key. Safe for development and testing. | Key prefixes make it easy to identify which environment a key belongs to at a glance. This follows the same live/test paradigm used by Stripe and other modern APIs. **A key's roles do not limit which of your routes it can call.** Roles are enforced on the `/v1` management API only; the proxy does not evaluate them, so any active key can call any enabled route in its mode, whatever roles it holds. Contain proxied use with each route's **Require authorized clients** setting and **IP allowlist** — see [Creating API keys](/api-reference/api-keys/create). ## Creating API Keys ### Via the Dashboard 1. Log in to the [KnoxCall Dashboard](https://knoxcall.com). 2. Navigate to **Settings** in the sidebar. 3. Scroll to the **API Keys** section. 4. Click **Create API Key**. 5. Give your key a descriptive name (e.g., "CI/CD Pipeline", "Backend Server"). 6. Copy the key immediately -- it will only be shown once. API keys are displayed only once at creation time. If you lose a key, you must revoke it and create a new one. KnoxCall never stores keys in plain text after initial creation. ### Via the API You can also create API keys programmatically: ```bash theme={"dark"} # $TOKEN is a minted OAuth access token — see the Quick start above curl -X POST https://api.knoxcall.com/v1/api-keys \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Deployment Key" }' ``` Response: ```json theme={"dark"} { "data": { "key_id": "ak_3f8a9b2c", "api_key": "tk_live_newkey789...", "key_prefix": "tk_live_newk", "key_type": "standard", "name": "Deployment Key", "message": "Store this API key securely. It will not be shown again." }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` The `api_key` field in the response is the only time the full API key is returned. Store it securely. ## Production vs. Sandbox KnoxCall provides completely separate production and sandbox environments, following the same live/test key paradigm popularized by Stripe. Routes, secrets, environments, webhooks, vaults, crypto keys, PKI authorities, OAuth clients, and workflows are all isolated between the two modes — nothing leaks across. **Management API:** `https://api.knoxcall.com/v1` **Proxy traffic:** `https://{slug}.knoxcall.com/{route}` * Uses `tk_live_` or `AKE` prefixed keys * Routes proxy real traffic to live upstream APIs * Secrets contain real credentials * Usage counts against your plan limits * Changes affect your live integrations immediately **Management API:** `https://sandbox.knoxcall.com/v1` **Proxy traffic:** `https://sandbox-{slug}.knoxcall.com/{route}` * Uses `tk_test_` prefixed keys * Routes, secrets, and clients are isolated from production * Safe for development, testing, and CI/CD * No impact on production traffic or billing * Mirrors production API behavior exactly Using a test key against the production API (or vice versa) will return a `403` error with the type `wrong_key_type`. Make sure your environment configuration matches the correct base URL and key type. ### Dashboard toggle The admin dashboard has a **Live / Test** toggle in the bottom-left of the sidebar (look for the flask icon). Click it to switch the entire dashboard between modes — every page (Routes, Secrets, Vaults, API Keys, Webhooks, etc.) refetches and shows only the data for the active mode. An amber banner across the top of the screen confirms when you are in Test mode. While in Test mode: * Creating an API key automatically produces a `tk_test_` test key (the standard / Enterprise Access Key options are hidden). * Creating any other resource (route, secret, vault, etc.) stamps it as sandbox — it is invisible from Live mode. * Existing live resources are not visible; you start with an empty sandbox until you populate it. Switching back to Live mode restores the production view immediately. Your selection persists across browser sessions. ### Using the SDK in sandbox mode Both the Node and Go SDKs accept a `sandbox: true` option which auto-resolves the management and proxy base URLs to the sandbox hostnames: ```typescript Node.js theme={"dark"} import { KnoxCall } from "@knoxcall/sdk"; const client = new KnoxCall({ tenant: "acme", sandbox: true, // → https://sandbox.knoxcall.com + https://sandbox-acme.knoxcall.com }); // Use a tk_test_ key — required for sandbox. ``` ```go Go theme={"dark"} import "github.com/knoxcall/sdk-go/knoxcall" client, err := knoxcall.New(knoxcall.Options{ Tenant: "acme", Sandbox: true, // → https://sandbox.knoxcall.com + https://sandbox-acme.knoxcall.com APIKey: "tk_test_...", }) ``` Equivalent environment variables work everywhere (CI, Docker, etc.): set `KNOXCALL_BASE_URL=https://sandbox.knoxcall.com` and `KNOXCALL_PROXY_BASE_URL=https://sandbox-{slug}.knoxcall.com`. ### Audit logs Every audit log entry is stamped with `details.sandbox: true|false` so you can filter live vs test activity in the Audit Logs page or via the `/v1/audit-logs` endpoint. Background jobs (cron, queue workers) always emit `sandbox: false`. ## Making Authenticated REST Requests The [SDK](#using-the-sdk-recommended) is the recommended path — it mints, caches, and refreshes tokens for you. Calling the REST API directly, mint a short-lived OAuth token (see [Quick start](#quick-start--oauth-21)) and pass it in the `Authorization` header as a Bearer token: ### Examples ```bash cURL theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) curl https://api.knoxcall.com/v1/routes \ -H "Authorization: Bearer $TOKEN" ``` ```python Python theme={"dark"} import os import requests headers = { # OAuth access token minted via POST /oauth/token "Authorization": f"Bearer {os.environ['KNOXCALL_ACCESS_TOKEN']}" } # List all routes response = requests.get( "https://api.knoxcall.com/v1/routes", headers=headers ) routes = response.json()["data"] for route in routes: print(f"{route['name']} - {route['target_base_url']}") ``` ```javascript Node.js theme={"dark"} // OAuth access token minted via POST /oauth/token const TOKEN = process.env.KNOXCALL_ACCESS_TOKEN; const response = await fetch("https://api.knoxcall.com/v1/routes", { headers: { Authorization: `Bearer ${TOKEN}` } }); const { data: routes } = await response.json(); routes.forEach(route => { console.log(`${route.name} - ${route.target_base_url}`); }); ``` ```php PHP theme={"dark"} true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer {$token}" ] ]); $response = curl_exec($ch); curl_close($ch); $data = json_decode($response, true); foreach ($data['data'] as $route) { echo "{$route['name']} - {$route['target_base_url']}\n"; } ``` ### Legacy: Long-Lived API Keys Long-lived keys (`tk_live_…`, `tk_test_…`, `AKE…`) remain fully supported on the Management API. Pass the key exactly where the token goes: ```bash theme={"dark"} curl https://api.knoxcall.com/v1/routes \ -H "Authorization: Bearer tk_live_abc123..." ``` ### Legacy: Using the x-api-key Header If your HTTP client or framework makes it difficult to set the `Authorization` header, you can pass a long-lived API key in the `x-api-key` header instead: ```bash cURL theme={"dark"} curl https://api.knoxcall.com/v1/routes \ -H "x-api-key: tk_live_abc123..." ``` ```python Python theme={"dark"} import requests response = requests.get( "https://api.knoxcall.com/v1/routes", headers={"x-api-key": "tk_live_abc123..."} ) ``` ```javascript Node.js theme={"dark"} const response = await fetch("https://api.knoxcall.com/v1/routes", { headers: { "x-api-key": process.env.KNOXCALL_API_KEY } }); ``` ```php PHP theme={"dark"} true, CURLOPT_HTTPHEADER => [ "x-api-key: " . getenv('KNOXCALL_API_KEY') ] ]); $response = curl_exec($ch); curl_close($ch); ``` ## Authentication Errors When authentication fails, the API returns one of these error types: | Error Type | Status | Cause | Solution | | - | - | - | - | | `authentication_required` | 401 | No API key provided | Add the `Authorization` or `x-api-key` header to your request | | `invalid_api_key` | 401 | Key is invalid, revoked, or expired | Check that the key is correct and has not been revoked in the dashboard | | `wrong_key_type` | 403 | Test key used on production, or live key used on sandbox | Match the key type to the correct base URL | | `subscription_inactive` | 403 | Tenant subscription is paused or cancelled | Update your billing information in the dashboard | Example error response: ```json theme={"dark"} { "error": { "type": "invalid_api_key", "message": "The API key provided is invalid or has been revoked.", "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ## Security Best Practices An API key can call your routes with your credentials injected, and do whatever its roles allow on the management API. Never embed one in frontend JavaScript, mobile apps, or any code that runs in the browser. Store API keys in environment variables or a secrets manager. Never hard-code them in source files or commit them to version control. Create new keys and revoke old ones on a regular cadence. If a key is compromised, revoke it immediately from the dashboard. Attach the narrowest role each key needs — roles limit what it can do on the management API — and create a separate key per service so you can revoke one without the others. Roles do not limit which routes a key can call: require authorized clients or an IP allowlist on each route. ### Environment Variable Setup On a developer machine, prefer `knoxcall login` — no environment variables at all. For CI and servers, [create an OAuth client](#create-an-oauth-client) (dashboard → **Settings → API → OAuth Clients**) and set `KNOXCALL_CLIENT_ID` + `KNOXCALL_CLIENT_SECRET` — every SDK picks them up automatically. `KNOXCALL_API_KEY` remains supported as the legacy spelling for a long-lived key. ```bash Linux / macOS theme={"dark"} # Add to your shell profile (~/.bashrc, ~/.zshrc, etc.) export KNOXCALL_CLIENT_ID="..." export KNOXCALL_CLIENT_SECRET="..." # Or use a .env file with dotenv echo 'KNOXCALL_CLIENT_ID=...' >> .env echo 'KNOXCALL_CLIENT_SECRET=...' >> .env # Legacy long-lived key (still supported) export KNOXCALL_API_KEY="tk_live_abc123..." ``` ```powershell Windows (PowerShell) theme={"dark"} # Set for current session $env:KNOXCALL_CLIENT_ID = "..." $env:KNOXCALL_CLIENT_SECRET = "..." # Set permanently for current user [System.Environment]::SetEnvironmentVariable("KNOXCALL_CLIENT_ID", "...", "User") [System.Environment]::SetEnvironmentVariable("KNOXCALL_CLIENT_SECRET", "...", "User") ``` ```yaml Docker / docker-compose.yml theme={"dark"} services: app: environment: - KNOXCALL_CLIENT_ID=... - KNOXCALL_CLIENT_SECRET=... # Or reference a .env file env_file: - .env ``` ```yaml CI/CD (GitHub Actions) theme={"dark"} # Prefer workload identity federation — zero stored secrets (see # "Workload identity federation" above). Otherwise store the client # secret as a repository secret and reference it: env: KNOXCALL_CLIENT_ID: ${{ secrets.KNOXCALL_CLIENT_ID }} KNOXCALL_CLIENT_SECRET: ${{ secrets.KNOXCALL_CLIENT_SECRET }} ``` Never add `.env` files containing credentials to version control. Add `.env` to your `.gitignore` file. ### Key Rotation Procedure 1. Create a new API key in the [dashboard](https://knoxcall.com) or via the API. 2. Update your application's environment variables with the new key. 3. Deploy the updated configuration. 4. Verify that requests succeed with the new key. 5. Revoke the old key in the dashboard. Both the old and new keys will work simultaneously until you revoke the old one. This allows zero-downtime rotation. # Changelog Source: https://docs.knoxcall.com/api-reference/changelog Dated, notable changes to the KnoxCall Management API and first-party SDKs. # API Changelog Notable changes to the KnoxCall Management API (`/v1`) and the first-party SDKs. Newest first. Backwards-compatible additions (new fields, new endpoints, new error types) ship without a version bump; we call out anything that changes existing behavior. **A change to existing behavior, in the dashboard (`/admin`) and on the Management API (`/v1`).** A route can carry a standing `decrypt` or `detokenize` field action (`POST /v1/routes/{routeId}/actions`). Attaching one already needed the action's own permission (`transit:decrypt` / `vaults:detokenize`), and a token issued to a person could never do it. **Moving the route it runs on did not:** an Editor could change its base URL in the dashboard, and any key with `routes:write` could on `/v1`, and every later call through the route then sent the opened values to the new host (a request-side action), or opened whatever the new host returned (a response-side action). **What changes.** Giving such a route a destination it does not already store — a new `target_base_url`, a new `method_configs[].target_url` (active or dormant), or a new environment carrying a URL the route has never had, including the environments a collection's defaults provision (apply to routes, reset to defaults, provision from the collection, moving the route into a collection with default environments) — or making it **start sending** to a URL it only stores (turning on a disabled environment or method-specific configs, allowing a method that reaches a base URL the allowed methods used to bypass, copying an idle URL into a base URL, or duplicating an environment whose URL the route only stores into one that serves traffic, so a URL written while nothing asked cannot be switched on later; a URL counts as already sent to only when a `GET`, `POST`, `PUT`, `PATCH` or `DELETE` request reaches it today, and a change is judged as if the environment's switches were open, so two saves sent together cannot each slip through and combine): * **in the dashboard** is an **Owner or Admin** act with a fresh second factor (a security key, passkey or authenticator code), an optional reason, the shared reveal budget, and a `plaintext.reveal` audit entry (`"surface": "route.destination_change"`, naming the routes, the new hostnames and the actions) written **before** the change. **Editors lose this**, with or without **Reveal secrets**. Refusals: `403 reveal_role_denied` for an Editor or Viewer; `403` with `"code": "reveal_step_up_required"` and `"action_scope": "plaintext.reveal"` until an Owner or Admin verifies (the dashboard asks and retries); `429` / `503` from the budget; `503 reveal_audit_unavailable`. Applying a collection to several routes takes **one** verification. **One Save asks once.** The route page sends everything a Save changes in an environment (the Target URL, headers and body rules, allowed methods, security defaults and the **Serve traffic** switch, which is now staged like the other settings instead of applying on click) as one request, so a Save asks for the verification at most once and a cancelled or refused verification saves nothing. Pushing a Target URL to every environment is one request and one verification, all or nothing. The audit entry for such a Save names everything it changed. A double-click on any of the page's commands sends the change once and asks once, and while one is being saved, every other control on the page that can change where the route sends is disabled. Also fixed: **Sync environment** and the compare panel's copies of the signature and rate-limit settings now save them; on an environment that already had a configuration they were reported as synced and not stored. A save that finishes after you switch environment no longer puts the previous environment's settings back on the page (the next per-method change could store them in the environment you switched to). * **on `/v1`** (`PATCH /v1/routes/{id}`, `PUT /v1/routes/{id}/environments/{env}`): a token issued to a **person** is refused `403 forbidden`, whatever the role; a **machine** credential needs the action's own permission — `403 forbidden` if the key's policy lacks `transit:decrypt` on the action's key or `vault:detokenize` on its vault, `403 insufficient_scope` for a narrowed OAuth token. Allowed changes are recorded as `route.destination_change` before they are written. Nothing is written on any refusal; do not retry it unchanged. * **the onboarding assistant's** route tools refuse it and point to the route page. **What does not change.** Re-sending a URL the route already stores, removing one, turning an environment off, deleting an environment override, duplicating or renaming an environment (onto URLs the route already sends to), and every route without a `decrypt` / `detokenize` action (an `encrypt` or `tokenize` action closes data and does not count). No request or response shape changes; `GET /admin/routes/{id}` gains `opening_actions` (kind and direction) for the dashboard's note. **A change to existing behavior on the Management API (`/v1`).** `HEAD` is not a documented operation on any `/v1` endpoint and no first-party SDK sends one, but the API answers it the way a web server does: it runs the `GET` and returns the status and headers without the body. On the read endpoints listed below that happened without the permission check the `GET` makes. A credential whose role or policy refused the `GET` with `403 forbidden` was answered on a `HEAD` with the real status and `Content-Length`. It never received a response body, but it could tell whether a route, client, webhook or request-log entry with a given id existed, and how large a list was. A `HEAD` to these endpoints is now refused exactly as the `GET` is, with the same `403 forbidden`: * `/v1/api-keys` * `/v1/clients`, `/v1/clients/{id}`, `/v1/clients/{id}/credentials` * `/v1/environments` * `/v1/routes`, `/v1/routes/{id}`, `/v1/routes/{id}/logs`, `/v1/routes/{id}/environments` * `/v1/webhooks`, `/v1/webhooks/event-types`, `/v1/webhooks/hmac-formats`, `/v1/webhooks/{id}`, `/v1/webhooks/{id}/logs` * `/v1/audit-logs`, `/v1/audit-logs/events` * `/v1/logs`, `/v1/logs/{request_id}`, `/v1/logs/{request_id}/proof` `/v1/routes/{routeId}/actions` already checked a `HEAD`, but made one of the two checks its `GET` makes. It now makes both. **Nothing changes for a credential that may make the `GET`.** Its `HEAD` is answered as before. Every other `/v1` endpoint already made the same permission check on a `HEAD` as on a `GET`. One related change on the same endpoints, for requests that were never served. Each group of endpoints recognizes a fixed set of request methods (not every URL in a group has an operation for each of them): * `/v1/api-keys`: `GET`, `POST`, `DELETE` * `/v1/audit-logs` and `/v1/logs`: `GET` * `/v1/clients`, `/v1/environments`, `/v1/routes` and `/v1/webhooks`: `GET`, `POST`, `PUT`, `PATCH`, `DELETE` `HEAD` counts as `GET` in every group, and `OPTIONS` is unchanged. What a request with any other method receives depends on which side of its group's set the method falls: * **A method outside the set** is now answered `405 method_not_allowed` in the standard error envelope, with an `Allow` response header that lists the group's methods: `Allow: GET, HEAD, POST, DELETE` on `/v1/api-keys`, `Allow: GET, HEAD` on `/v1/audit-logs` and `/v1/logs`, and `Allow: GET, HEAD, POST, PUT, PATCH, DELETE` on `/v1/clients`, `/v1/environments`, `/v1/routes` and `/v1/webhooks`. No role or policy permission is checked for it, so the answer does not depend on what the credential may do. Examples: a `PATCH` to `/v1/api-keys/{id}`, a `POST` to `/v1/audit-logs`, or a method such as `PROPFIND` on any of these endpoints. Before this change such a request was answered `404`, in the plain body described in the next item, so code that matched on that `404` should match on `405`. The `Allow` header names the methods of the endpoint group, not of the one URL: a method it lists can still have no operation at a particular URL (the next item), and it does not list `OPTIONS`. Two checks still come first: a request with no valid credential is answered `401`, and an access token restricted by OAuth scope is answered `403 insufficient_scope` if its scope does not cover the endpoint. * **A method inside the set that the particular URL has no operation for is unchanged.** Examples: a `PUT` to `/v1/webhooks/{id}`, or a `DELETE` to `/v1/webhooks/{id}/logs`. The permission that method maps to on the endpoint group is checked first (`webhook:update` and `webhook:delete` in those two examples), so a credential without it is answered `403 forbidden`. A credential that holds it is answered `404` as before, and not in the standard error envelope: the body is a plain `{"error": "not_found", "message": "No route for …"}`, or plain text for a `GET`. **Two webhook endpoints now ask for their own permission however the URL is written.** `GET /v1/webhooks/{id}/logs` requires `webhook:read_logs`, and `POST /v1/webhooks/{id}/test` requires `webhook:test`. The API also accepts those URLs with a trailing slash or in another letter case, and for those spellings it checked `webhook:read` and `webhook:create` instead. A credential allowed to read webhooks but not their delivery logs could read the logs at `/v1/webhooks/{id}/logs/`, and one allowed to create webhooks but not to send a test delivery could send one at `/v1/webhooks/{id}/test/`. Every spelling now requires `webhook:read_logs` and `webhook:test`. If a request to one of those spellings starts returning `403 forbidden`, the credential was relying on the weaker check: grant it the permission the endpoint names. **A change to existing behavior, for people only.** API keys and OAuth clients keep every role and policy they hold, including a custom role they share with a person. A person's access in a workspace comes from their team role. Until now a person could also hold a custom role, or a custom policy attached to them directly, from before those stopped being assignable to people. **What is removed.** From each person: * a custom role, when every policy in it contains only `allow` rules; * a custom policy attached to them directly, when it contains only `allow` rules. The role or policy itself is not deleted or changed, and it stays on any API key or OAuth client that holds it. **What is left in place.** A custom role or policy that contains a `deny` rule — or any rule that is not a well-formed `allow` — stays on the person, with everything in it, because removing a deny would give them access they do not have today. Its `allow` rules keep applying as well. The roles KnoxCall provides are not touched. **What a person can lose** is whatever the removed role or policy allowed beyond their team role: * on `/v1`, with a token issued to them (`knoxcall login`, the device grant, or an app they authorized with the authorization-code flow), those calls now answer **403 `forbidden`**; * in the dashboard, the `details` of audit-log entries, if the custom role or policy was what let them read it; * when they create an API key or OAuth client, the roles they may give it. Those are bounded by what the person is allowed, so that bound can shrink with it. Nothing a person was denied becomes allowed. **The record.** Each person who lost a role or policy has one audit-log entry in the workspace, `membership.custom_grants_retired`, naming what was removed, what was kept and why. Its actor is empty: KnoxCall made the change, not a member. If someone needs that access back, give them the team role that covers it, or give the automation an API key or OAuth client with a key role. **A change to existing behavior in the dashboard (`/admin`).** Two more actions join the Class B reveals in the 2026-10-06 entry below. **Who can do them does not change:** both were already Owner and Admin only, and still are. * **Issuing an mTLS client certificate** on a client's **Credentials** section ("Issue a new cert", `POST /admin/clients/{id}/credentials` with `"kind": "mtls_thumbprint"` and `"data": { "mode": "issue" }`). The response carries the certificate's private key, so it now works exactly like issuing a certificate from a certificate authority (`POST /admin/pki/roots/{name}/issue/{role}`): a fresh second factor, an optional reason, a `plaintext.reveal` audit entry written **before** the key is generated, and the shared reveal budget. If the check refuses, no certificate is issued and no credential is added. **Registering a certificate you generated yourself** (`"data": { "sha256_thumbprint": … }`) or an IP address hands nothing back and is unchanged: an Editor can still do it, with no second factor. * **The PII token-map view** of the AI gateway (`GET /admin/ai-gateway-pii/token-map/{conversationId}`), which decrypts a conversation's tokenized values and shows the first three characters and the length of each. Three characters and an exact length narrow a card number, an SSN or an email a long way, so it is now a reveal: a fresh second factor, a `plaintext.reveal` audit entry naming the conversation **before** anything is decrypted, and the shared reveal budget. A request the gateway cannot address (a name with no `?principal=`) is still a `400`, answered before anything is asked for or recorded. The refusals are the ones listed below: `403` with `"code": "reveal_step_up_required"` and `"action_scope": "plaintext.reveal"` until you verify (the dashboard asks for your factor and retries), `503 reveal_audit_unavailable` if the entry cannot be written, and `429` / `503` from the budget. The entries these actions already wrote (`pki.leaf.issue` with `via: client_credential`, and the credential's own `create` entry) are still written. **Nothing on the Management API (`/v1`) changes.** `POST /v1/clients/{id}/credentials` with `"mode": "issue"` still refuses a token issued to a person (2026-09-30), and a machine credential holding `client:create` still issues. There is no `/v1` token-map endpoint. **A change to existing behavior in webhook delivery.** Besides audit-log entries, the `audit.event` stream carries a record of proxied requests. Until this change that record was delivered to every `audit.event` webhook in the workspace, Live and Test alike: a Test request's record reached your Live endpoint, signed with the Live webhook's signing secret and listed in the Live webhook's delivery log, and a Live request's record reached your Test endpoint the same way. It now goes only to the `audit.event` webhooks in the request's own mode, as the `request.*` events always have. A request record whose mode cannot be determined is delivered to neither. Audit-log entries (configuration changes, sign-ins, rotations) are unchanged. They belong to the workspace rather than to a mode, and are still delivered to `audit.event` webhooks in both modes. **A change to existing behavior, in the dashboard (`/admin`) and on the Management API (`/v1`).** It finishes what the 2026-10-06 entry below started for captured requests. **What changes.** A route's saved sample payload — `payload_structure`, which the dashboard saves from a payload capture or a pasted sample, and each method's copy of it in method-specific mode — is now returned as its **structure** on every read, for every role: the same keys and nesting, with each value replaced by its type, `""`, `""` or `""` (`null` stays `null`; a list keeps each item, shown the same way). That covers the route page and its environments, the responses to saving a sample, the workspace export, the before-and-after details of a route change in the audit log, `GET /v1/routes/{id}`, the response to `PUT /v1/routes/{id}/environments/{env}` (which returned the sample with no masking at all, including inside `method_configs`), and the configuration bundle sent to a self-hosted agent. Until now every member, a Viewer included, could read the values, with only values under sensitive-looking field names masked. **The values are a reveal.** An Owner or Admin can click **Show sample values** on the route's **Body** tab (`GET /admin/routes/{id}/payload-sample?environment={env}`, with `&method={METHOD}` for one method's sample). It works like the other reveals in the 2026-10-06 entry: a fresh second factor, an optional reason, a `plaintext.reveal` audit entry written before the values are read, and the shared reveal budget. Values under sensitive-looking field names are still masked there. Editors and Viewers are refused (`403` with `"code": "reveal_role_denied"`). Asking for a sample that is not saved — a method with no sample of its own, or an environment with none — is a `404` before anything is asked for, counted or recorded. **On `/v1` there is no way to read the values**, with any credential. The field's type does not change (it is still an object), so no SDK changes a signature; the SDKs, the API reference and the Postman collection now describe the field as structure only. **Saving is unchanged.** Pasting or capturing a sample still stores what you sent; reading it back shows its structure — including to the Editor who pasted it. Saving a route's settings without touching its sample keeps the stored values, and so does saving the sample editor unchanged. On `/v1`, `PATCH /v1/routes/{id}` with a `method_configs` whose samples are the structure you read back keeps each stored sample too. Editing the sample saves what you typed: to edit the values, reveal them first. Copying one method's configuration to another, or syncing an environment's configuration from another environment in the dashboard, copies the sample's structure, not its values (a target sample with the same structure keeps its own values). Duplicating an environment still copies everything, values included, on the server. The workspace export carries the structure only: an export is not a reveal (it asks for no second factor), so it does not carry the values. **A change to existing behavior in the dashboard (`/admin`).** Nothing on the Management API (`/v1`) changes here: a credential still needs the matching permission there. Since the 2026-09-30 entry a token issued to a person never carries `transit:decrypt`, `vault:detokenize`, `pki:issue` or `dyn_db_cred:mint`; listing a vault's tokens and transit rewrap on `/v1` are unchanged by this entry and do not take the check below. **What is a Class B reveal.** Each of these hands you plaintext, a live credential, or captured traffic, and each now runs through the same reveal check as showing a secret's value (the 2026-10-05 entry below): * listing a vault's tokens (`GET /admin/vaults/{vault}/tokens`) and detokenizing one (`GET /admin/vaults/{vault}/tokens/{token}`); * transit decrypt and rewrap (`POST /admin/crypto/keys/{name}/decrypt`, `/rewrap`); * issuing a dynamic database credential (`POST /admin/dyn-db-credentials/connections/{name}/creds/{role}`); * issuing a certificate and its private key (`POST /admin/pki/roots/{name}/issue/{role}`); * creating a **server-mode** agent (`POST /admin/agents` with `"mode": "server"`), rotating a server-mode agent's secret, creating an install token, and switching an agent to proxy mode (`switch_to_proxy`, or `switch_mode` to `proxy`); * creating a **Can view** or **Can manage** share (`POST /admin/secrets/{id}/shares`); * the secret-store migration review: an item's value, approve, reject and commit; * a request log's captured headers and bodies (`GET /admin/logs/{request_id}?include=payload`) and its archived bodies (`GET /admin/logs/{request_id}/file`); * a webhook delivery's payload (`GET /admin/webhook-logs/{id}?include=payload`); * an outbound webhook's signing secret (`GET /admin/webhooks/{id}/secret`); * retrieving a request captured at a route's payload-capture URL (`GET /admin/routes/{id}/check-payload-capture/{token}`, once the request has arrived). **Who.** The Owner and Admins only. An Editor cannot, even with **Reveal secrets** turned on (that setting covers secret values, certificates and credentialed workflow tests). Where the route's own Owner/Admin check answers first, the refusal is that check's `403`; otherwise it is `403` with `"code": "reveal_role_denied"`. The migration review also still needs the Owner-granted review delegation for an Admin, checked first. Every surface in the list was already Owner and Admin only except one: an Editor could retrieve a request captured at a payload-capture URL, and now cannot. An Editor can still start a capture, but only the person holding the capture link can retrieve it and a capture expires after 5 minutes, so in practice retrieving a capture is now an Owner and Admin action. **This does not yet cover the saved result:** the dashboard saves a retrieved capture as the route's sample payload, and every member of the workspace can still read that sample (on the route's pages and in the `/v1` route response), with values under sensitive-looking field names masked. The 2026-10-07 entry above puts those reads behind the same Owner and Admin rule. **Every use asks for a fresh second factor** (an authenticator code, a passkey or a security key; never an emailed code), and one verification buys one use. The refusal is `403` with `"code": "reveal_step_up_required"`, `"requires_step_up": true` and the scope to verify for: `"plaintext.reveal"`, or `"secret.migration"` for the migration review (the scope those four actions have always taken; they now take it once per action, inside the same check). The dashboard asks for your factor and retries, and offers an optional reason on an explicit reveal. **The audit entry comes first.** Each use writes a `plaintext.reveal` entry before anything is decrypted, minted, read or changed; if it cannot be written the request is refused with `503 reveal_audit_unavailable`. Each use also counts against the per-person reveal budget shared with secret values (60 per 5 minutes; `429 reveal_budget_exceeded`, or `503 reveal_budget_unavailable` when it cannot be counted). The entries these actions already wrote (`vault.detokenize`, `transit.decrypt`, `pki.leaf.issue`, `migration.item.reveal` and the rest) are still written. **Captured payloads are an act of their own.** Opening a request log or a webhook delivery now shows its **metadata** to everyone (status, timing, path, response code, any error). Its captured headers and bodies are withheld until an Owner or Admin clicks **Reveal payload**; downloading or previewing an archived body is a reveal of its own, so previews no longer load when you open the page. The seeded roles only ever gave captured payloads to Owners and Admins, so an Editor or Viewer on a seeded role loses nothing they had; one holding a **custom** `log:read_payload` grant loses the payload. A tenant policy that denies `log:read_payload`, `audit_log:read_details` (for an `audit.event` delivery) or `webhook:reveal_secret` still refuses an Admin, with `"code": "reveal_policy_denied"`, before the verification is spent. **Also changed.** A vault's **Tokens** tab no longer lists the tokens when you open it: click **Show tokens**. Transit rewrap now refuses a platform operator who is not a member of your workspace inside the handler, as decrypt always has. An **intercept** agent, switching an agent back to intercept, a **Use only** share and the signing secret returned when a webhook is created or regenerated are not reveals and are unchanged. **A change to existing behavior for Editors and Viewers.** The Management API (`/v1`) is unchanged. This is the signed-in `/auth` route behind Settings → Account. **What changed.** `PATCH /auth/me` saves your own profile (name, company name, phone, timezone, date format, notification and consent settings) and, from the same form, six company billing fields: `country`, `address`, `city`, `postal_code`, `tax_id_type` and `tax_id_value`. Those six belong to a workspace, not to you. They are written to the workspace and to its billing customer, and they print on its invoices. Until this change anyone who could sign in could save them, whatever their role, and they were saved to the first workspace you joined. For someone who accepted an invitation, that is the workspace that invited them. Now a request that carries any of the six answers `403` unless you are the Owner or an Admin of that workspace: ```json theme={"dark"} { "error": "Only the workspace's Owner or an Admin can change its company billing address or tax ID. Nothing was saved.", "code": "tenant_billing_requires_owner_or_admin", "tenant_id": "" } ``` **When it refuses, nothing is saved**, including any profile fields sent in the same request. Send those on their own and they save as before. A request that carries none of the six is unchanged for every role. Your own role in that workspace decides it: a KnoxCall platform operator is treated the same way as anyone else. **The same request also needs what the Billing page needs.** Owners and Admins are affected by two more conditions: * **An enrolled second factor.** Without one, the request answers `403` with `"error": "mfa_required"` and an enrolment link, exactly as every dashboard route does. * **A session that belongs to that workspace.** Some sign-ins produce a session that belongs to one workspace: a workspace's own single sign-on, accepting an invitation to a workspace, a magic link sent for a workspace, a passkey used on a workspace's own address, and finishing two-factor sign-in on any of those. Such a session can change the six fields only for that workspace. For any other workspace the answer is `403` with "This session is scoped to a different tenant. Sign in again to switch tenants." The workspace-session rule also applies to onboarding (`POST /auth/complete-onboarding`) when it would update a workspace you already own; the second-factor requirement does not apply there. **A country or postal code the workspace cannot store is now a `400` before anything is saved.** `country` must be a two-letter code (for example `NZ`, `AU`, `US`) and `postal_code` at most 20 characters, the same rule signup and the New Tenant dialog already apply. The answer is `400` with an error starting `Invalid tenant address:`. Before this change, a value like `"USA"` saved your profile fields first and then failed with a `500`. **Which workspace.** The six fields still go to the first workspace you joined, not to the one selected in the dashboard. This change does not alter that. The Billing page's tax ID (`PUT /admin/billing/tax-id`) acts on the selected workspace and has always required the Owner or an Admin. **A change to existing behavior in the dashboard, for every role.** This entry changes the dashboard's own routes (`/admin`) only. Nothing on the Management API (`/v1`) changes here: the `/v1` endpoints that return decrypted or live credential material — among them `GET /v1/secrets/{id}/oauth2/token` and `GET /v1/vaults/{vault}/tokens/{token}` (see the 2026-08-26 entry) — keep the controls they have today, and changes to them will come in a later change with its own entry. For what a token issued to a person can reach on `/v1`, see the 2026-09-30 entry "A token issued to a person no longer reaches owner/admin or reveal-class operations on /v1". **What asks for a second factor now.** Five dashboard actions hand you plaintext, and each one now needs a fresh verification, every time, whoever you are (Owner, Admin, or an Editor with **Reveal secrets** turned on): * showing a secret's value (`GET /admin/secrets/{id}/value`); * showing the value of a secret another workspace shared with yours (the same route, on the shared secret's entry in your workspace); * viewing or downloading a certificate (`GET /admin/secrets/{id}/certificate/download`), with or without its private key — a `pfx`, `p12` or `key` certificate *is* its private key; * testing a workflow step against your stored credentials (`POST /admin/workflows/{id}/nodes/{nodeId}/test`); * reading a workflow's webhook trigger URL (`GET /admin/workflows/{id}/webhook-url`), whose token is the trigger's bearer credential. Before this change only two of these asked for one: revealing a secret shared from another workspace, and a platform operator's emergency access to a secret's value or certificate. Your own workspace's secrets, certificates and workflows did not. **The factor.** An authenticator code, a passkey or a security key. An emailed code does not count. Without a verification the request answers `403` with `"code": "reveal_step_up_required"`, `"requires_step_up": true`, `"action_scope": "plaintext.reveal"` and `"allowed_methods": ["passkey", "2fa"]`; the dashboard asks for your factor and retries. A verification buys one reveal: the next reveal asks again. A verification made for any other action does not count here, and one made here counts nowhere else. **The audit row comes first.** Every granted reveal writes a `plaintext.reveal` audit entry — the surface, the environment, Live or Test, when you verified, and an optional reason you can type in the dashboard (sent as the `X-Reveal-Reason` header) — **before** the value is decrypted. A shared secret's value belongs to the workspace that shared it, so that workspace gets a `plaintext.reveal` entry of its own too, also before the value is decrypted. That entry records your workspace and the request's IP address rather than your user account, and does not carry your reason. If either entry cannot be recorded, the reveal is refused with `503 reveal_audit_unavailable`. **Rate limit, failing closed.** Reveals share one per-person budget (60 per 5 minutes). Over it, `429 reveal_budget_exceeded` with `Retry-After`. If the budget cannot be counted (our rate-limit store is unavailable), the reveal is refused with `503 reveal_budget_unavailable` and `Retry-After`, never served unmetered. The same `503` now also applies to the other reveal screens (vault tokens, imported-credential review, captured request bodies), which used to answer that case with a `429`. **Who may reveal is unchanged**: the Owner and Admins, and an Editor only with **Reveal secrets** turned on. A refusal for that reason answers `403` with `"code": "reveal_role_denied"` (a Viewer) or `"code": "reveal_tick_required"` (an Editor without the setting), and does not offer a verification. Testing a workflow step is the one exception for a Viewer: the test runs the step for real, which a Viewer may never do, so a Viewer is refused earlier, by the read-only check, with a `403` that carries no `code`. **If a check cannot run** (our database did not answer), the reveal is refused, never served: a check that could not run is never treated as passed. When it is one of the reveal checks (your membership, your **Reveal secrets** setting or your verification) the answer is `500` with `"code": "reveal_check_failed"`; when it is the workspace or resource lookup that runs before them, it is a `500` without that code. Retry it. **No role gains or loses a permission, and the Management API (`/v1`) is unchanged.** These are changes to the signed-in `/auth` routes behind Settings → Security and the signup flow. **Adding a passkey now asks for the same verification as adding an app or a security key.** On an account that already holds any factor (an authenticator app, a security key or a passkey), `POST /auth/passkey/register-options` answers `403` with `"requires_step_up": true`, `"action_scope": "mfa.credential"` (it was `null`) and `"allowed_methods": ["passkey", "2fa"]` until you verify for `mfa.credential`. A verification made for anything other than managing your sign-in factors (revealing a secret, approving an OAuth consent, exporting your data) no longer counts here. One made for another factor-management action (adding an app or a security key, removing a key or passkey, disabling or re-keying the app) now counts here, because they share the `mfa.credential` scope; before this change it did not, since only a verification tied to no action counted. An account with no factor yet still adds its first passkey with the emailed code from `POST /auth/passkey/challenge`, and no verification. **`POST /auth/passkey/challenge` names the same scope when it declines.** For an account that already holds a factor it mails no code, as before, and its `403` now carries `"action_scope": "mfa.credential"` (it was `null`): the verification the next call needs. **The verification `POST /auth/2fa/verify` records when you confirm your first factor is now for `mfa.credential`** (it was not tied to any action). Signup is unchanged: adding a passkey straight after setting up the app still needs no second prompt. In the five minutes after it, that verification no longer counts for actions outside factor management, such as an OAuth consent; those ask you to verify again. It now counts instead for the other factor-management actions (adding a security key, removing a key or passkey, disabling or re-keying the app), which it did not before. **No role gains or loses a permission, and the Management API (`/v1`) is unchanged.** These are changes to the signed-in `/auth` routes behind the dashboard's "Verify your identity" step and the OAuth consent page. **Security keys can step up.** An account whose only second factor is a security key could not complete a step-up verification before, so every action that asks for one was closed to it: revealing a secret, downloading a certificate's private key, tenant KMS unseal, rotate and revert, the migration routes, minting OAuth-client and SCIM credentials, SAML changes, ownership transfer, and removing a factor. Two new endpoints fix that: `POST /auth/2fa/webauthn/step-up-options` returns a WebAuthn challenge for the keys and passkeys the account holds, and `POST /auth/2fa/webauthn/verify-step-up` (`credential`, `challenge`, `action_scope`) records the verification. It is recorded as `passkey`, so any `403` whose `allowed_methods` lists `passkey` can now be cleared with a security key. An emailed code still does not count at any of these gates. **Adding an authenticator app now asks for the factor you already have.** On an account that holds a security key or a passkey, `POST /auth/2fa/setup` answers `403` with `"requires_step_up": true`, `"action_scope": "mfa.credential"` and `"allowed_methods": ["passkey", "2fa"]` until you verify with that key or passkey for `mfa.credential`, the same rule that already applied to re-keying an app. A verification made for a different action does not count. Before this change a signed-in session alone could add an app. An app setup started before this change on such an account, and never confirmed, must be started again: confirming it answers `400 "2FA not set up. Call /2fa/setup first."`. **Confirming an app (`POST /auth/2fa/verify`) leaves no verification behind for an account that already had a factor.** After a re-key, or after adding an app to a security key or passkey, the next action that asks for a step-up asks again. An account enrolling its very first factor is unchanged. **New `409 mfa_enrolment_changed`** from `POST /auth/2fa/verify`, when the app setup was restarted elsewhere (another tab) between your code being checked and the app being saved. Nothing is enabled or replaced; scan the newest QR code and enter a code from it. **Adding a security key now asks for the same verification as adding an app.** On an account that already holds any factor (an authenticator app, a security key or a passkey), `POST /auth/2fa/webauthn/register/options` answers `403` with `"requires_step_up": true`, `"action_scope": "mfa.credential"` (it was `null`) and `"allowed_methods": ["passkey", "2fa"]` until you verify for `mfa.credential`. A verification made for anything other than managing your sign-in factors (revealing a secret, approving an OAuth consent, exporting your data) no longer counts here. One made for another factor-management action (removing a key or passkey, disabling or re-keying the app) now counts here, because they share the `mfa.credential` scope; before this change it did not, since only a verification tied to no action counted. An account with no factor yet still enrols its first security key without one. *(Corrected 2026-10-05: this paragraph first said such a verification "still" counted.)* **`POST /auth/2fa/setup` can answer the same `409 mfa_enrolment_changed`**, when an account with no factor yet starts or restarts app setup while another tab is confirming the app. Nothing is written: the app confirmed in the other tab stays the live one, with its backup codes. **A change to existing behavior on your routes' proxy traffic** (`{slug}.knoxcall.com`). The Management API (`/v1`) is unchanged. Your plan's account-wide limit is a token bucket. It used to hold a full minute of your per-minute rate, so you could send that whole minute's quota in one second. It now holds **ten seconds** of it: | Plan | Per minute (unchanged) | Most at once — was | Most at once — now | | - | - | - | - | | Free | 100 | 100 | 17 | | Starter | 1,000 | 1,000 | 167 | | Pro | 5,000 | 5,000 | 834 | | Enterprise | 10,000 | 10,000 | 1,667 | Your per-minute rate is unchanged: sent evenly, every request of it is still admitted. A burst larger than the new bucket is refused with `429` and `X-RateLimit-Layer: tenant` sooner than before. A per-tenant override that states its own bucket size keeps it. On the wire: * `X-RateLimit-Tenant-Limit`, `X-RateLimit-Limit` on a tenant `429`, the `limit` in that `429`'s body and `limit=` in `X-Knox-RateLimit-Tenant` still report your **per-minute** rate — the same numbers as before. * **New:** `X-RateLimit-Tenant-Burst`, `X-RateLimit-Burst` on a tenant `429`, `burst` in its body, and `burst=` appended at the end of `X-Knox-RateLimit-Tenant` state the bucket. * `X-RateLimit-Tenant-Remaining` / `remaining=` is still what you can send right now, so it now tops out at the bucket rather than at your per-minute rate. * `X-RateLimit-Tenant-Reset`, `reset=` and `Retry-After` still mean "when the bucket is full again", which is now at most about ten seconds away instead of a minute — so a client that waits `Retry-After` after a tenant `429` waits seconds, not up to a minute. See [Rate limiting](/advanced/rate-limiting) and [Response metadata](/api-reference/response-metadata). **A change to existing behavior on proxied traffic**: your routes (in the cloud and on a self-hosted proxy), the AI Gateway, the ephemeral proxy and inbound-webhook forwards. No endpoint, field or error type is added or removed. `/v1/proxy` and `/v1/ai/*` forward fewer of your request headers; every other `/v1` endpoint is unchanged. A request reaches KnoxCall through an edge and a reverse proxy, which add headers describing the connection to us. Some of them were being passed on to your upstream, and so were two of KnoxCall's own. The headers below are no longer forwarded. Each is matched by its **exact name**, in any letter case; there is no prefix rule. | Group | Headers no longer forwarded | | - | - | | KnoxCall's own | `X-KnoxCall-Env` (an internal routing label our edge sets), `X-KnoxCall-Origin` (the marker the SDKs' route-aware interceptor sends), `X-KnoxCall-API-Key` (the API key header of KnoxCall's interactive demo; nothing outside the demo reads it) | | The caller's address | `X-Real-IP`, `True-Client-IP`, `CF-Connecting-IPv6`, `CF-Pseudo-IPv4` | | Client certificate, from a proxy in front of KnoxCall | `X-Client-Cert-Verify`, `X-Client-Cert-Thumbprint`, `X-Client-Cert-Subject` | | Cloudflare edge detail | `CF-Worker`, `CF-EW-Via`, `CF-Connecting-O2O` | | Cloudflare visitor location | `CF-IPCity`, `CF-IPContinent`, `CF-IPLatitude`, `CF-IPLongitude`, `CF-Region`, `CF-Region-Code`, `CF-Metro-Code`, `CF-Postal-Code`, `CF-Timezone` | | Cloudflare checks on the caller | `CF-Bot-Score`, `CF-Verified-Bot`, `CF-JA3-Hash`, `CF-JA4`, `Exposed-Credential-Check`, `Malicious-Uploads-Detection` | | Cloudflare client certificate | `CF-Cert-Presented`, `CF-Cert-Verified`, `CF-Cert-Revoked`, `CF-Cert-Issuer-DN`, `CF-Cert-Subject-DN`, `CF-Cert-Issuer-DN-RFC2253`, `CF-Cert-Subject-DN-RFC2253`, `CF-Cert-Issuer-DN-Legacy`, `CF-Cert-Subject-DN-Legacy`, `CF-Cert-Serial`, `CF-Cert-Issuer-Serial`, `CF-Cert-SHA256`, `CF-Cert-SHA1`, `CF-Cert-Not-Before`, `CF-Cert-Not-After`, `CF-Cert-SKI`, `CF-Cert-Issuer-SKI` | | Cloudflare Access | `Cf-Access-Jwt-Assertion` | These were already withheld and still are: `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Proto`, `X-Forwarded-Server`, `CF-Connecting-IP`, `CF-IPCountry`, `CF-Ray`, `CF-Visitor`, `CDN-Loop`, `Cookie`, and the control headers `X-KnoxCall-Key`, `X-KnoxCall-Route`, `X-KnoxCall-Environment`, `X-KnoxCall-Signature`, `X-KnoxCall-Agent-Id`, `X-KnoxCall-Agent-Token` and `X-KnoxCall-Client-Assertion`. The complete list is now published at [Request headers KnoxCall does not forward](/api-reference/request-headers). **What to check.** If your caller, or a proxy of your own in front of a self-hosted KnoxCall proxy, sends one of these names and your upstream reads it, it no longer arrives. `X-Real-IP` and `True-Client-IP` are the likely ones; `X-Client-Cert-Verify`, `X-Client-Cert-Thumbprint` and `X-Client-Cert-Subject` if you terminate mutual TLS yourself. Nothing in the response says a header was withheld: the request is forwarded without it. **If your upstream needs one of them.** On a route, including the route behind an AI Gateway agent, add the header to the route's injected headers. A header the route injects is sent, with the value the route configures rather than the one your caller sent, so this suits a fixed value: there is no template helper for the caller's IP address. The ephemeral proxy and inbound-webhook forwards have no injected headers, so on those paths the header cannot be sent under that name; send the value under a name that is not on the list. **Still yours, still forwarded.** A header that only resembles one of these is not affected. In particular the `CF-Access-Client-Id` / `CF-Access-Client-Secret` pair and the `cf-access-token` header, for an upstream behind Cloudflare Access, keep working. **No response shape changed.** No endpoint, field or error type is added or removed. What changes is the value in fields that already exist. **`user_id` names the person behind a user-bound token.** An action taken with a token issued to a person — the `knoxcall` CLI, the device grant, an authorization-code app — used to be recorded with `user_id: null`, as if no one had done it. It is now recorded with that person's id. Actions by an API key or a machine OAuth client still carry `user_id: null`. Entries written before this change are not rewritten. **Two new keys inside `details`** on entries made through `/v1`: `actor_credential` (`{ "kind": "api_key" | "oauth_client", "id": "" }`) names the credential that made the request, and `actor_user_source: "v1_bearer"` marks a `user_id` that came from a user-bound token rather than a dashboard session. KnoxCall sets both from the authenticated request; a request body cannot set or change them. You will see this in `GET /v1/audit-logs`, `GET /v1/audit-logs/events`, `audit.event` webhook payloads, SIEM exports and data-subject exports. If you filter the audit log by `user_id` to find dashboard activity, add a check that `details.actor_user_source` is absent. **Two behaviour changes for existing clients, one additive field.** **`details` is now sanitised on `/v1`.** `GET /v1/audit-logs` and `GET /v1/audit-logs/events` used to return `details` exactly as stored. They now apply the same sanitiser the dashboard always has. Values under credential-shaped keys read `***REDACTED***`, and `user:password@` userinfo is removed from any URL (older `webhook.created` entries could carry one). Identifiers such as the acting `api_key_id` are kept. For an API key or OAuth client holding `audit_log:list` this is the only change: you keep every other field of `details`. **User-bound tokens get the dashboard's rule.** A token issued to a person (the `knoxcall` CLI, device and authorization-code grants) sees `details` only if that person may: an Admin or Owner. For a Viewer or Editor each row now carries `details: {}` and `details_redacted: true`. The rest of the row is unchanged. The dashboard's audit detail view already applied this rule; this change extends it to the list and `/v1` paths that did not. **`details_redacted`** (boolean) is new on every row of both endpoints. It is additive: a redacted `details` is always an empty object, never a string, so typed clients decode it unchanged. The SDK types carry it as an optional field. **`details` is always an object or `null`.** An entry whose stored `details` is not a JSON object (a self-hosted agent can record a bare string, array or number) is now returned wrapped as `{"value": ...}`, sanitised like any other. Previously it came back as stored, which typed clients could fail to decode. **`audit.event` webhooks.** Their payloads now carry sanitised `details`, in the delivery and in your delivery log. Creating a webhook subscribed to `audit.event`, or making any change to one that stays subscribed (a new URL, but also a rename or pausing it), needs what reading `details` needs: `audit_log:list` on a key or OAuth client (every built-in key role that can manage webhooks already has it), or an Admin or Owner behind a user-bound token. Otherwise the request is refused with `403 forbidden`. Deleting one is not affected. An OAuth token narrowed by scope must also hold a scope that reads the audit log (`audit-logs:read`), or it is refused with `403 insufficient_scope`. Existing subscriptions keep delivering. **A security fix that narrows what some people's CLI tokens can do, effective immediately.** It applies to tokens issued to a *person* — `knoxcall login`, the device grant and apps you authorize with the authorization-code flow. API keys and OAuth clients acting as themselves are unchanged. Such a token acts with its holder's role. For **Restricted (member)** and **Developer** members those roles allowed, on `/v1`, operations the dashboard reserves for owners and admins. They are now refused with **403 `forbidden`**: * decrypting (`POST /v1/decrypt`, `POST /v1/crypto/keys/:name/decrypt`), rewrapping and signing with transit keys, managing them, listing keys and reading a key's metadata (`GET /v1/crypto/keys`, `GET /v1/crypto/keys/:name`); * issuing PKI certificates and managing roots and roles; * minting dynamic database credentials, revoking their leases, and managing connections and roles; * fetching a live OAuth2 access token (`GET /v1/secrets/:id/oauth2/token`); * listing or creating API keys, and creating, changing or rotating OAuth clients; * registering agents, and creating, changing or minting tokens for AI gateways (so `knoxcall ai create` and `knoxcall ai mint` need an owner or admin). The dashboard refuses the same person all of these but one: it shows transit keys and their metadata to any member. On `/v1`, listing keys and reading a key's metadata need an owner or admin, because `/v1` gates them on the key-management permission. A **Read-only** member's token can no longer list API keys, mint or revoke wrap tokens (`POST` / `DELETE /v1/wrap/tokens`), or report egress observations (`POST /v1/wrap/egress-observations`): a person's token now also needs `secret:update` for the first two and `route:create` for the third. API keys keep doing these on read alone. **Owners and admins** keep what they had, except one thing: a token issued to a person can no longer issue a client's mTLS certificate (`POST /v1/clients/:id/credentials` with `mode: issue`), because the response carries the certificate's private key. Issue it in the dashboard, register a certificate you generated yourself by its `sha256_thumbprint` (that still works), or use an API key. The rule behind that holds for every role: a token issued to a person never decrypts, detokenizes, issues a certificate that comes back with its private key, mints a database credential or fetches a live OAuth2 token. The owner and admin roles KnoxCall seeds never gave a token those; a custom role that grants one explicitly is now refused too. Nor can such a token hand one of those to a machine credential: attaching a role that carries one to a new API key (`POST /v1/api-keys` with `role_ids`) is refused with **403 `privilege_escalation`**, and rotating the secret of an OAuth client that holds one with **403 `forbidden`**. Those return live credential material; do them in the dashboard where it offers them, or with an API key or OAuth client an owner or admin grants the permission in the dashboard. Nothing changes in the dashboard. If a script run with `knoxcall login` now gets a 403 for one of these, ask an owner or admin for an API key that carries the permission it needs. If the database is unavailable while KnoxCall checks a person's role for one of these, the request now answers **503 `dependency_unavailable`** with `Retry-After` — retry it — rather than a 403 that blamed the role. **Breaking for narrowed OAuth tokens that create credentials.** A tenant narrows an access token — say to `api-keys:write` or `oauth-clients:write` — so that whoever holds it can do that one thing and nothing else. Four endpoints let such a token hand itself more: * `POST /v1/api-keys` minted a key with no roles, which on the proxy can invoke every route in its mode. A narrowed token now gets `403 privilege_escalation` and **mints no key at all**, with or without `role_ids`. * `POST /v1/agents` returned an agent credential, which the proxy also accepts for every route. A narrowed token now gets `403 privilege_escalation`. * `POST /v1/oauth-clients` and `PATCH /v1/oauth-clients/{id}` accepted any `allowed_scopes` — `["*"]` included — and returned the new client's secret. From a narrowed token, the client must now be one the token could itself hold: every scope listed verbatim in the token's own scope, and `client_credentials` as the only grant type. An update is judged on the client as it will be afterwards, so a narrowed token also cannot re-enable or re-point a client that is already wider than it. * `POST /v1/oauth-clients/{id}/rotate-secret` from a narrowed token is refused for a client wider than the token, since rotation hands back that client's secret. **Who is affected.** Only access tokens that were *deliberately narrowed*. API keys (`tk_`, AKE), tokens minted with no scope (including every `knoxcall login` session and the Terraform provider's default token), and tokens holding `*` are unaffected. To create these credentials from automation, use an API key or an unnarrowed token. **Also: `allowed_scopes` may no longer be set to a universal list.** `["*"]` and `["*:*"]` mean "every scope", exactly as an empty list does, and an empty list has been refused since 2026-08. Creating an OAuth client or a workload-identity binding with one, or changing a client's list to one, now returns `400`. A client that **already** holds a universal list keeps working and keeps minting tokens; re-sending the list it already has (for example when saving its other settings) still succeeds. Replace `*` with the scopes the client actually needs when you next edit it. **One behaviour change for existing clients.** If you send `pan_egress_allowed_hosts` in a request body to `POST /v1/routes`, `PATCH /v1/routes/:id` or `PUT /v1/routes/:id/environments/:env`, you now get a **400** instead of having the field silently ignored. A `null` or an empty array is still accepted as the no-op it is, so a client that round-trips a config object unchanged is unaffected. **What the field is.** A route may now carry its own list of destinations allowed to receive a raw card number, for that route in that environment, on top of whatever your workspace-wide allowlist permits. Before this, a tenant who needed to send a clear card number to one processor had to open their whole workspace to that destination. The two lists are a union — a route grant never narrows what the workspace already allows. **It is deliberately not settable with an API key.** A credential that can proxy must not be able to authorise itself a destination for raw card numbers, and the second-factor gate that protects this setting cannot apply to a credential that has no user. Set it in the dashboard on the route's **Security** tab, or with `PUT /admin/pan-egress/routes/:routeId/environments/:env` as an owner or admin. **`/v1` reads do not return the field**, so there is no read-then-write round trip that could trip the 400 on a value you did not choose. **It stores hosts, not a flag.** Editing a route's `target_base_url` after granting therefore fails closed rather than carrying your consent to a destination you never approved. **Additive, and Test data space only. Nothing changes in Live, and nothing changes for a request that does not use one of these numbers.** You could not exercise a failed payment on KnoxCall without a processor account and a real decline. Test mode now has four documented card numbers that do it for you: | Number | What happens when a request detokenizes it and sends | | - | - | | `4242 4242 4242 4242` | Forwarded to your destination like a real card | | `4000 0000 0000 0002` | **Not forwarded** — `402 sandbox_test_card_declined` | | `4000 0000 0000 3220` | **Not forwarded** — `402 sandbox_test_card_requires_action` | | `4000 0000 0000 0069` | Forwarded, and tokenizes with `card_expires_on: "2020-01-31"` unless you supply your own `card_exp_*` | They work on every server-side detokenize-and-send path: `POST /v1/proxy`, the `/wg` base-URL gateway, your wrap subdomain, and a configured Route carrying a `detokenize` field action. Tokenize them into a `pan` vault the way you tokenize any card. **Two new error types, on the existing envelope.** No new response field and no new endpoint. `sandbox_test_card_declined` and `sandbox_test_card_requires_action` both answer **402**, which `plan_limit` also uses — branch on `error.type` (or the `X-Knox-Error` header), never on the status alone. The response carries no `X-Knox-Destination-Status`, so a wrapped provider SDK reads it as a KnoxCall error rather than as its provider's decline. **It is a simulation, and it is our shape, not any processor's.** KnoxCall is a vault and a proxy — it does not authorize or capture, so it cannot tell you what your processor would have said. What it does is refuse to forward and say exactly why: nothing reaches your destination at all on a decline or a 3-D Secure outcome. Three things worth knowing: * **Live is untouched.** All four are ordinary card numbers in the Live data space. * **Only a value a vault released is recognised.** A test number pasted into a request body as clear text is still refused by the raw-card-number egress control (`403 raw_pan_egress_refused`). Test cards are not a way around it. * **A simulated decline still counts as a detokenize.** The card really was decrypted, so it writes the usual `vault.detokenize` audit row, counts against your detokenize meter once, and appears in the vault's Destinations view attributed to the host you named. Browser capture of card numbers through hosted fields is unchanged and still switched off — see [Elements → Card fields](/sdks/elements#card-fields) for the reason and when it opens. The same table serves that path on the day it does; it is not a second implementation waiting to be written. **Additive, and honest about what it does not do yet.** Two new response fields on card tokens, and **both are `null` on every token today.** | Field | Shape | | - | - | | `card_funding_type` | `"credit"` / `"debit"` / `"prepaid"`, or `null` | | `card_issuing_country` | ISO 3166-1 alpha-2, upper case (`"IE"`), or `null` | They appear on the tokenize response, the bulk tokenize response and every row of `GET /v1/vaults/{vault}/tokens`, beside `card_expires_on`. There is no new request field: both are derived from the card's first six digits at tokenize time, never supplied by you. **Why they are empty, in plain terms.** Naming a card's funding type and issuing bank from its first six digits requires a commercially licensed BIN table. KnoxCall has not licensed one, and whether we do is an open business decision. Everything around that table is built and shipped — the storage, the lookup, the API shape, the SDK models — so the day we license one the fields populate with no change to your integration. Until then they are `null`. **So treat both as optional indefinitely.** They will also be `null` for every non-`pan` vault, and for any card whose issuer a future table does not carry. Code that requires either field to be present will break on cards that are perfectly valid. `brand` and the last four digits are unaffected and have always worked: those come from the *published* issuer ranges, which need no licence, and the `pan` token string itself preserves them. **Additive. Nothing changes for a request that does not ask for it.** A card on file stops working when the card expires, and nothing on KnoxCall's side errors when it does — we hold a token, not an issuing relationship. The first anyone hears about it is a decline at your payment processor, often on a renewal. Card tokens can now carry the card's own expiry, and KnoxCall tells you before that happens. **1. Two new optional fields on tokenize.** `POST /v1/vaults/{vault}/tokens` and `POST /v1/vaults/{vault}/tokens/bulk` (per value) accept: | Field | Shape | | - | - | | `card_exp_month` | integer, 1–12 | | `card_exp_year` | integer, **four digits** — `2029`, never `29` | Both or neither, and **only on a vault whose `token_format` is `pan`**. A month without a year, a two-digit year, or either field offered to a `generic` / `ssn` / `email` vault answers `400 validation_error` — we refuse rather than quietly dropping it, because a quiet drop means no notice ever fires and you never find out. In the bulk form the refusal names the offending index and the whole batch is rolled back. **This is not `ttl_seconds`.** `ttl_seconds` (and the `expires_at` you get back) is how long *our token* lives. `card_exp_*` is when *the card* stops working. A token with no TTL at all can belong to a card that dies in fourteen months. **2. A new response field, `card_expires_on`.** The last day of the card's expiry month (`"2029-07-31"` — a card is valid through the end of its month), on the tokenize response, the bulk tokenize response and every row of `GET /v1/vaults/{vault}/tokens`. `null` for every non-`pan` token and for any `pan` token whose tokenize call named no expiry. **Tokens created before today carry `null`, and there is no backfill.** The expiry was never captured, so it is not in the stored ciphertext and we cannot recover it. Those tokens receive no expiry notice until the card is tokenized again. If you hold cards on file and want the notices, re-tokenize on the next successful charge or on the customer's next update. **3. A new webhook event, `vault.token.expiring`.** Subscribable like any other (`GET /v1/webhooks/event-types` lists it), delivered **60 days and again 30 days** before `card_expires_on`, at most once per token per threshold. It is scoped to the token's data space, so a Test-mode card only ever reaches a Test-mode webhook. ```json theme={"dark"} { "event": "vault.token.expiring", "timestamp": "2026-09-17T08:30:04.117Z", "data": { "token_id": "0f4a…", "token": "4111114725124116", "vault_id": "22c1…", "vault_name": "cards", "last4": "4116", "card_expires_on": "2029-07-31", "days_until_expiry": 60, "threshold_days": 60, "sandbox": false } } ``` `token` is the token you already store, not a card number — for a `pan` vault it preserves the real BIN and last four, which is what makes it useful in a "your card ending 4116 expires soon" message, and it is not chargeable anywhere. **The payload never carries the card number**: a delivery whose contents look card-shaped is refused server-side rather than sent. Each delivery is also written to your audit log as `vault.token.expiring` against the token's id, so a workspace piping `audit.event` into a SIEM receives it without subscribing to anything new. **The notice is a window, not a single day.** If the job that decides them misses a day, the notice catches up on the next run rather than being skipped — so a "60 days" event may arrive a few days late. It never arrives twice. **SDKs.** `tokenize` / `tokenize_bulk` take the two parameters and the token models carry `card_expires_on` in node, python, go, php and ruby. `constructEvent` needs no change: the event list has always been open for forward compatibility, so an existing SDK build receives and parses `vault.token.expiring` today. **Behaviour change, and it can stop a step that worked yesterday.** On 2026-09-14 KnoxCall began refusing a **payment card number sent in the clear** on `POST /v1/proxy`, on Routes, on the workflow HTTP step, on webhook delivery and on `/v1/ai/*`, unless the destination host is on the workspace's raw-card egress allowlist — which is **empty for every workspace by default**. That refusal now covers the paths it did not reach: | Where | What is scanned | What you see | | - | - | - | | **Send Email** step | subject, body, and every **attachment, decoded** — a card hidden in base64 is found | the step fails with the refusal message; nothing is sent | | **Send SMS** step | the message text | the step fails; nothing is sent | | **Database** step | every templated query **parameter**, and the SQL text | the step fails; **no connection is opened** | | **Inbound webhook forwarding** | the provider's body, as relayed | your receiver is **not** called. The provider still gets `200` — a retry could not fix it — and the response body says `verified but not forwarded`, never `forward failed` | | **`/v1/ai/*` MCP tool calls** | the tool call's **arguments** | JSON-RPC error `-32000` with the refusal message | | **`/v1/ai/*` SDK requests** | the request body | the request is refused before the provider is dialled | | **App connector steps** (catalog integrations) | the request body | the step fails; nothing is posted | **A refused workflow step is NOT retried**, even when the node has retries configured: no retry can change the outcome, and retrying would write one audit entry per attempt for one mistake. This is a change to node retry behaviour generally — any permanent refusal now ends the retry loop on the attempt that produced it. The step still **fails**, and the failure is still recorded. **What is NOT affected.** Detection is unchanged: 13–19 digits (spaces and hyphens tolerated) that pass the Luhn checksum **and** carry a recognised issuer prefix. An ordinary 16-digit order or invoice number is not affected, and a `pan`-vault **token** is excluded by lookup rather than by shape — tokens keep working exactly as before, which is the point. **The remedy** is the same one on every surface: tokenize the card into a vault whose `token_format` is `pan` and send `{{ token: … }}`, or — if the destination genuinely must receive the number — have an owner or admin add its hostname with `PUT /admin/pan-egress/allowed-hosts`. Adding a host requires a fresh second factor; removing one does not. There is deliberately **no `/v1` twin** of that endpoint: an API key that can proxy should not be able to authorise itself a destination for raw card numbers. Each refusal writes an `egress.raw_pan_refused` audit entry carrying the destination host and a masked sample (last four only, never the number) and increments a month-to-date counter readable at `GET /admin/pan-egress/allowed-hosts`. **Behaviour change for requests that detokenize a card number.** It affects you only if a request resolves a token from a vault whose `token_format` is **`pan`** — on `POST /v1/proxy` via `{{ token: ... }}`, or on a Route carrying a request-direction `detokenize` field action. Requests that reference no `pan` token are byte-for-byte unchanged: same headers upstream, same response, same logs. **1. The upstream's response is now re-tokenized.** Detokenize-and-send puts the real card number on the wire on purpose, and many processors echo it straight back — in the confirmation object, in a `Location` redirect, in a plain-text receipt. Until now KnoxCall returned that reply verbatim, so the number landed in your backend. Each occurrence of the number **this request opened** is now replaced with the token it came from, before the response reaches you — and before it reaches your outbound **webhooks**, the **workflow** event a Route response triggers, the S3 **response archive**, and the `response_body` field of the request log. Scope is exact, not a card scan: only the values this request opened, only `pan` vaults, and every spelling of the same number (`4111111111111111`, `4111 1111 1111 1111`, `4111-1111-1111-1111`, and the form-encoded `4111+1111+1111+1111` / `4111%201111%201111%201111`). A **different** card number your processor happens to mention, and any value from a `generic`, `ssn` or `email` vault, are left alone. Replacement covers JSON string fields at any depth, values sent as bare JSON numbers, object keys, form-encoded and text bodies, and header values such as `Location` — a header is **replaced, not stripped**, so a 3-D Secure redirect keeps working. **2. `409 pan_streaming_unsupported` on `/v1/proxy`.** A request that both detokenizes a `pan` token **and** asks for a streamed response is refused. A card number can straddle any chunk boundary the upstream picks, so a streamed reply cannot be re-tokenized honestly, and the combination is refused rather than served unprotected. **What to do:** send the card request without streaming — no `Accept: text/event-stream` header and no `"stream": true` in the body — or reference no `pan` token on the streamed call. **3. `Accept-Encoding: identity` upstream, and `502 response_not_retokenizable`.** On these requests KnoxCall now asks your upstream for an uncompressed reply, because a compressed body cannot be scanned. If the upstream compresses anyway — or answers with a binary body — the response is **withheld** rather than forwarded unscanned. **What to do:** have the upstream honour `Accept-Encoding: identity` for this call, or move the card traffic to a Route (the Route data plane decompresses `gzip`, `deflate` and `br` itself). **Do not blindly retry** — the upstream call already happened and may already have charged the card. **4. Routes: `502 relay_response_not_retokenizable`.** The Route equivalent, for a reply whose body cannot be decoded for scanning — a content encoding KnoxCall does not implement, a binary body, or one past the response-log ceiling. Same remedy, same warning about retrying. This is a **refusal**, not a KnoxCall fault: it is recorded in your audit log as `route.action.refused` and raises no operator alert. **5. Audit.** Every re-tokenized response writes a `vault.retokenize` entry naming the vault and how many occurrences were replaced — never the card number and never the token. A non-zero count is worth acting on: it means your processor is echoing card data back to you. On `/v1/proxy` the per-request `ephemeral_proxy.invoke` entry also carries `retokenized_occurrences`. Full detail: [Ephemeral Proxy — responses are re-tokenized](/essentials/proxy/ephemeral-proxy#responses-are-re-tokenized) and [Vaults — a processor that echoes the card number back](/essentials/vaults/overview#a-processor-that-echoes-the-card-number-back). **Behaviour change on every outbound path, and the one entry on this page most likely to affect you today.** KnoxCall now inspects the body of an outbound request for a payment card number in the clear. If it finds one, and the destination host is not on your account's **raw-card egress allowlist**, the request is refused and **never leaves the platform**. **The allowlist is empty for every existing account.** Nothing was migrated into it. So if you are sending real card numbers through KnoxCall today — to a processor, to your own service, to anything — those requests start receiving `403` the moment this ships, and keep receiving it until you either tokenize the number or allow that host. Please read the remedy below before you deploy against this. **Where it applies.** | Surface | Covered | | - | - | | `POST /v1/proxy` | Yes — both template mode (`{{ token: ... }}`) and transparent mode, and therefore `/wg` and your wrap subdomain | | A configured Route | Yes — all transports: direct, mTLS, VPN, static egress | | The workflow **HTTP Request** module | Yes — checked per dial, so a redirect re-send and a pagination body swap are each inspected, not just the first hop | | The AI gateway | Yes — a card in a prompt, in `messages`, or in tool arguments. A failover candidate is **not** tried: sending the same card to a different provider is not a recovery | | Outbound **webhooks** | Yes — the delivery is refused terminally and is not retried | **What counts as a card number.** 13–19 digits with spaces and hyphens tolerated, that pass the Luhn checksum **and** begin in a range payment cards are actually issued in. All three must hold, so an ordinary 16-digit order or account reference is not affected. The check reads the finalized outbound bytes *and* the parsed string values, so a card written as a JSON number, as an object key, or with escaped digits is found too. **What you get.** `403` with error type `raw_pan_egress_refused`. The message names the destination and the remedy and contains **no digit of the card**. Each refusal writes an `egress.raw_pan_refused` audit entry carrying the destination host and a masked sample (last four only), and increments a month-to-date counter you can read back. Nothing is forwarded, and no alert is raised on our side — this is your request being refused, not a KnoxCall fault. **What to do — two options.** 1. **Tokenize the number and send the token.** Create a vault with `"token_format": "pan"` and an `allowed_destinations` list, tokenize the card into it, and reference the token in your request as `{{ token: }}`. KnoxCall substitutes the real number on the way out, to a destination that vault allows, and re-tokenizes anything the processor echoes back (see the 2026-09-15 entry above). **This is the supported path and the reason vaults exist** — the card never sits in your own logs, your own database, or ours. 2. **Allow the destination for raw card data.** An owner or admin can add the host with `PUT /admin/pan-egress/allowed-hosts`, and read the current list and the refusal count with `GET /admin/pan-egress/allowed-hosts`. The list is replace-the-whole-list, so two people editing cannot silently union their changes. **Adding a host requires a fresh second factor; removing one does not** — shutting the door stays reachable during an incident. Entries are bare hostnames (`api.stripe.com`) or wildcard suffixes (`*.stripe.com`), the same pattern syntax as a vault's `allowed_destinations`. Changes are audited as `egress.pan_allowed_hosts_updated` with the previous list, so any change can be reverted from the audit trail alone. **What is unaffected.** A request carrying no card number takes no new code path and is byte-for-byte unchanged. Detokenize-and-send keeps working: a value KnoxCall itself just opened from a `pan` vault is exempt, because that vault already refused every host outside its own `allowed_destinations` before anything was decrypted. On the AI gateway, an agent with `pii_request_mode: 'tokenize'` is unaffected — the surrogate that mode mints is format-preserving, and is recognised as something the tokenizer produced rather than something you sent. **What this does not cover yet.** The refusal is not on every outbound path in the platform. MCP tool-call arguments and the SDK-level fetch wrapper on the AI gateway, inbound-webhook forwarding, the workflow email and SMS modules, and templated database-query parameters are **not** inspected today. A card number placed on one of those still leaves unscanned. Treat the refusal as a safety net under the tokenization workflow, not as a substitute for it. **Behaviour change on every tokenize endpoint.** A value that looks like a payment card number is now refused by any vault whose `token_format` is not `pan`, with a new error type, `wrong_format`, at `400`. The message names the format to use; it never echoes the value. | Endpoint | Before | Now | | - | - | - | | `POST /v1/vaults/{vault}/tokens` | `201` with a token | `400 wrong_format` | | `POST /v1/vaults/{vault}/tokens/bulk` | tokens for the whole batch | `400 wrong_format`, naming the index; **nothing is stored for any value in the batch** | | `POST /v1/client/tokenize` (hosted fields) | `400 tokenize_failed`, message stripped | `400 wrong_format` with the remedy | | A Route `tokenize` field action | the value was tokenized | `422 relay_value_rejected` (request direction) / `502` (response direction) | A `pan` vault is unchanged, and so is every value that is not card-shaped. **Who this can break, and it is not only people storing cards.** A value is treated as a card number when it is 13-19 digits after spaces and hyphens are removed, passes the Luhn check, **and** begins in a range cards are issued in. A tenant whose generic vault holds an identifier that happens to meet all three — an account or order reference in an issued BIN range with a Luhn check digit — will start receiving `400 wrong_format` for new values. Existing tokens are untouched: nothing is re-scanned, nothing expires, and detokenize is unaffected. **What to do.** If the value really is a card number, create a vault with `"token_format": "pan"` and an `allowed_destinations` list, and tokenize into that. If it is not, the leading digits are what tripped the check: an identifier your own system mints can avoid the issued-BIN ranges, or you can drop the Luhn check digit. There is deliberately **no opt-out flag** — a generic vault never holding a card number is what keeps tenants who do not use the card format outside any boundary later drawn around card data. **Not a shape change.** `wrong_format` is a new value in the existing `error.type` field on the existing `400` response; no request or response body changed shape, so no SDK update is required. **Breaking for narrowed OAuth tokens that create route field-actions.** A route action is a **standing grant**: once stored, every request through that route runs it unattended, for as long as the row exists, on the data plane — where the only scope checked is `routes:invoke`. So `POST /v1/routes/{routeId}/actions` with `"action": "detokenize"` turns vault tokens back into card numbers on every future request, and `"action": "decrypt"` opens `kc:` ciphertexts the same way. Until now both were satisfied by `routes:write`. Scope is derived from the request **path**, and the path is the same for all four actions — so a tenant who minted a partner a token narrowed to `routes:read routes:write` ("you may manage my routes") had also granted "you may read the values behind my vault tokens, forever". Revoking the token did not remove the action. | Request body | Required before | Required now | | - | - | - | | `"action": "detokenize"` | `routes:write` | `routes:write` **and** `vaults:detokenize` (or `vaults:*`) | | `"action": "decrypt"` | `routes:write` | `routes:write` **and** `transit:decrypt` (or `transit:*`) | | `"action": "tokenize"` | `routes:write` | unchanged — `routes:write` | | `"action": "encrypt"` | `routes:write` | unchanged — `routes:write` | `encrypt` and `tokenize` are deliberately unchanged: both **close** data — they seal a field to a key, or swap it for a token — and neither reads anything back. **Who is affected.** Only access tokens that were deliberately narrowed. An API key (`tk_`, `AKE`), and an OAuth client mirrored from one, carries no scope grant to narrow and is unaffected; its policy rules are the control there and they have not changed. **What you get.** `403 insufficient_scope`, with a `WWW-Authenticate` header naming the scope to mint, and **nothing is written** — the refusal runs before the policy check and before any vault or key is looked up. **What to do.** Add `vaults:detokenize` and/or `transit:decrypt` to the client's `allowed_scopes` and re-mint, exactly as for the [2026-08-26](/api-reference/changelog) narrowings. **Behaviour change for workflows started by an App Polling trigger — today the Slack *New Channel Message* trigger.** If more new items appeared between two polls than one poll may start workflow runs for, the extra items were **skipped and never fired**. They are no longer skipped: they fire on the following poll. A polling trigger starts at most **25 runs per poll**, and it remembers its place with a cursor. When a poll found more than 25 new items it started the first 25 — and then moved the cursor to the newest item it had *seen*, not the newest it had *started*. Everything in between was behind the cursor on the next poll and was never picked up. There was no error, no failed run and nothing in run history: the only sign was the missing runs. The trigger now asks the provider for **at most as many items as one poll may start**, so there is no un-started remainder for the cursor to move past. When the provider has more waiting, the poll walks further within the same tick, up to a bounded number of requests. So: | In one polling interval | Before | Now | | - | - | - | | 25 or fewer new items | 1 run each | unchanged — 1 run each | | 40 new items | 25 runs, **15 silently skipped** | 25 runs now, the other 15 on the next poll | | 100 new items | 25 runs, **75 silently skipped** | 25 runs per poll until all 100 have run | **What to expect.** A busy Slack channel that used to produce a steady 25 runs per minute will now produce runs for the messages it was dropping, so **your workflow may execute more often than it did last week** — that is the skipped work arriving, not a duplicate. Nothing is run twice: each item still starts exactly one run. A large backlog drains at 25 items per poll (one poll a minute by default), so a burst of a few hundred items takes a few minutes to work through. Workflow executions are metered against your plan's execution allowance as usual, and a backlog that exceeds it is deferred and retried rather than dropped. **What to do.** Nothing. If a high-volume channel now starts more runs than you want, narrow the trigger to the channel you care about or add a Condition step as the first node. One residual we would rather state than leave you to find. A trigger whose provider returns **newest first** — Slack's channel messages, Twilio's messages — reads back from the newest item towards your cursor, and one poll only reaches so far back. An interval that produces more items than a single poll reaches can still leave the OLDEST of them behind, because the next poll starts from the newer cursor. How far each such trigger reaches is stated in that trigger's own description in the workflow editor, and the platform logs the poll that stopped short. Triggers that page **oldest first** — Notion, Airtable — have no such bound: the remainder is always newer than the committed cursor and is picked up on the next poll. **Breaking for narrowed OAuth tokens that call `/v1/proxy`.** The one-shot ephemeral proxy is a single handler that answers **every** HTTP method, and a call to it resolves and decrypts an escrowed upstream credential and spends it against the host you name. Until now the required scope was derived from the verb, so `GET`, `HEAD` and `OPTIONS` were satisfied by `proxy:read` and the remaining methods by `proxy:write`. Neither is a read or a write of a resource. Every method now requires the same grant: | Endpoint | Was | Now requires | | - | - | - | | `/v1/proxy` (any method) | `proxy:read` (GET/HEAD/OPTIONS), `proxy:write` (others) | `proxy:invoke` (or `proxy:*`) | The proxy takes all of its inputs from headers — `X-Knox-Proxy-URL`, `X-Knox-Upstream-Auth-Secret` — and needs no body, so a bodyless `GET /v1/proxy` did exactly what a `POST` did. A token narrowed to `proxy:read` for an integration meant to observe was a full credential-spending grant. `proxy:write` is excluded for the same reason `secrets:write` and `vaults:write` are excluded above: escrowing a credential (`POST /v1/wrap/credentials`) is not permission to spend it. This mirrors `routes:invoke` on the route-configured data plane. **Who is affected.** OAuth clients whose `allowed_scopes` is a narrowed list that calls `/v1/proxy`. API keys, key-mirrored clients, `knoxcall login`, tokens granted `*:*`, and the wrapped-SDK gateway (`/wg` base URLs minted by `POST /v1/wrap/tokens`, which authenticate with the wrap token rather than an OAuth grant) are unchanged. **What to do.** Add `proxy:invoke` to the client's `allowed_scopes` and re-mint. A refused call returns `403 insufficient_scope` naming the scope in both the `WWW-Authenticate` header and the body. **Breaking for OAuth clients that omit `scope`.** `client_credentials` has always minted a narrowed client's whole `allowed_scopes` list when the request named no `scope`. The `authorization_code` and device grants did not: a request that named no scope produced a token with an **empty** scope grant, and an empty grant is read as *unnarrowed* — it reached every `/v1` endpoint, ignoring the client's `allowed_scopes` entirely. All three grants now behave the same way: | The request names | The client's `allowed_scopes` | The token now carries | | - | - | - | | no `scope` | `["routes:read"]` | `routes:read` | | no `scope` | empty (API-key mirror, CLI client) | empty — unchanged, these inherit the key | | `routes:read` | `["routes:read", "secrets:read"]` | `routes:read` — unchanged | The same resolution runs when the token is minted, so an authorization code or device code issued **before** the client's `allowed_scopes` was narrowed no longer mints the wider set. If the narrowing leaves nothing the credential can carry, the exchange is refused with `400 invalid_scope` rather than minting an empty (unnarrowed) grant; re-authorize to obtain a code within the client's current list. **Who is affected.** OAuth clients with an explicit `allowed_scopes` list whose integration omits the `scope` parameter at `GET /oauth/authorize` or `POST /oauth/device_authorization`. Those calls previously produced an unrestricted token; they now produce exactly what the client is configured to allow. API keys, key-mirrored clients and `knoxcall login` (the CLI client) carry no allow-list to resolve against and are unchanged. **What to do.** Nothing, if the client's `allowed_scopes` already lists what your integration uses. If a call starts returning `403 insufficient_scope`, the header and body name the exact scope to add to the client and re-mint. The consent screen now lists those permissions instead of "No specific permissions requested". **Breaking for narrowed OAuth tokens.** Two `/v1` `GET` endpoints hand back decrypted material rather than a description of it, and until now both were satisfied by the ordinary read scope for their resource family. They now require a scope that names the capability: | Endpoint | Was | Now requires | | - | - | - | | `GET /v1/secrets/{id}/oauth2/token` | `secrets:read` | `secrets:oauth_token` (or `secrets:*`) | | `GET /v1/vaults/{vault}/tokens/{token}` | `vaults:read` | `vaults:detokenize` (or `vaults:*`) | The first returns a **live upstream OAuth2 access token**, refreshing it at the provider when it is stale — so it mints a credential rather than reading one. The second returns the **cleartext value** behind a vault token. Neither is a read of the resource, and granting `secrets:read` ("Read secret metadata") or `vaults:read` ("Read vaults") was never intended to carry them. **Who is affected.** Only tokens that were *deliberately narrowed*. If you authenticate with an API key (`tk_`/`AKE`), or with an OAuth client that KnoxCall mirrors from an API key, nothing changes — those callers carry no scope grant to narrow. You are affected if your OAuth client has an explicit `allowed_scopes` list, because a `client_credentials` request that names no `scope` is minted with that whole list. **What to do.** Add `secrets:oauth_token` (and/or `vaults:detokenize`) to the client's `allowed_scopes`, then re-mint. A refused call returns `403 insufficient_scope` and names the exact scope to request in both the `WWW-Authenticate` header and the response body: ```http theme={"dark"} HTTP/1.1 403 Forbidden WWW-Authenticate: Bearer error="insufficient_scope", error_description="Requires secrets:oauth_token", scope="secrets:oauth_token" ``` Universal grants (`secrets:*`, `vaults:*`, `*:*`, `*`) continue to reach both endpoints. **Unified error envelope + `X-Request-Id`.** Every failure on `/v1` now returns one canonical shape — `{ "error": { "type", "message", "request_id" } }` — with a **bare-UUID** `request_id` (no `req_` prefix). The same ID is now returned on **every** response, success or error, in the new `X-Request-Id` header, so you can correlate a request even without parsing the body. Added the `forbidden` type (403) for RBAC denials. See the new [Errors reference](/api-reference/errors). **Cross-worker rate-limit headers.** Management API rate limits are enforced per API key across all workers. Responses now carry `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` (epoch seconds) whenever a limit is configured — not only on `429`s — plus `Retry-After` on a `429`. See the new [Rate limits reference](/api-reference/rate-limits). **Dual idempotency-key header.** Mutating requests accept an idempotency key as either `X-Idempotency-Key` or the standard `Idempotency-Key` spelling. A retry replays the stored response with `X-Idempotent-Replay: true`; reuse with a different body returns `422` (`idempotency_key_reuse`); a still-in-progress request returns `409` (`request_in_progress`). See the new [Idempotency reference](/api-reference/idempotency). **Typed OAuth2 / certificate secret creation in SDKs.** The first-party SDKs gained typed helpers for creating OAuth2 and mTLS-certificate secrets, matching the dedicated [create OAuth2 secret](/api-reference/secrets/create-oauth2) and [create certificate secret](/api-reference/secrets/create-certificate) endpoints. # Create Client Source: https://docs.knoxcall.com/api-reference/clients/create Creates a new authorized client ## Create Client ```text theme={"dark"} POST /v1/clients ``` Creates a new authorized client. ### Request Body | Field | Type | Required | Description | | - | - | - | - | | `name` | string | Yes | Display name for the client | | `ip_address` | string | Yes | IP address or CIDR range (e.g., `203.0.113.42` or `10.0.0.0/16`) | | `type` | string | No | Client type: `user` or `server` (defaults to `server`) | | `ip_notes` | object | No | Metadata about the IP address | | `description` | string | No | Human-readable description | ### Response Returns the created client row. Unlike [Get Client](./get), this response does **not** include the `route_assignments` array (route assignments only appear on `GET /v1/clients/:id`) and includes the raw `tenant_id` column. ```typescript Node.js theme={"dark"} import { KnoxCall } from "@knoxcall/sdk"; const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET const created = await client.clients.create({ name: "Production Backend", ip_address: "203.0.113.0/24", type: "server", description: "Main backend servers" }); ``` ```python Python theme={"dark"} from knoxcall import KnoxCall client = KnoxCall() # credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET created = client.clients.create( name="Production Backend", ip_address="203.0.113.0/24", type="server", description="Main backend servers" ) ``` ```bash cURL theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) curl -X POST https://api.knoxcall.com/v1/clients \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Production Backend", "ip_address": "203.0.113.0/24", "type": "server", "description": "Main backend servers" }' ``` ### Errors | Status | Type | Description | | - | - | - | | 400 | `validation_error` | Missing required fields or invalid type | | 403 | `plan_limit` | Client limit reached for your subscription plan | | 409 | `conflict` | A client with this name already exists in this mode. Client names are unique per tenant per mode, so the same name may exist in both production and sandbox, but not twice within one | # Client Credentials Source: https://docs.knoxcall.com/api-reference/clients/credentials List, add, update, and delete authentication credentials attached to a client Each client can have one or more **credentials** — the actual authentication materials KnoxCall checks when a request arrives. Two credential kinds are available in v1: | Kind | Description | | - | - | | `ip` | An IP address or CIDR range. Matched against the incoming source IP. | | `mtls_thumbprint` | A client certificate SHA-256 thumbprint for mTLS authentication. KnoxCall can issue the certificate for you (`mode: "issue"`) or you can register an existing cert's thumbprint. | *** ## List Credentials ```text theme={"dark"} GET /v1/clients/:id/credentials ``` Returns all credentials attached to a client. ### Path Parameters | Parameter | Type | Description | | - | - | - | | `id` | uuid | The client ID | ### Response ```json theme={"dark"} { "data": [ { "id": "cred-uuid-1", "client_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "tenant_id": "b2c3d4e5-f6a7-8901-bcde-f23456789012", "kind": "ip", "label": "Primary office", "data": { "value": "203.0.113.0/24", "notes": "" }, "enabled": true, "expires_at": null, "created_at": "2026-01-15T09:30:00.000Z", "updated_at": "2026-01-15T09:30:00.000Z", "last_matched_at": "2026-05-20T14:22:01.000Z" } ], "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ### Errors | Status | Type | Description | | - | - | - | | 404 | `not_found` | Client not found | *** ## Add Credential ```text theme={"dark"} POST /v1/clients/:id/credentials ``` Attaches a new credential to a client. ### Path Parameters | Parameter | Type | Description | | - | - | - | | `id` | uuid | The client ID | ### Request Body | Field | Type | Required | Description | | - | - | - | - | | `kind` | string | Yes | Credential type: `ip` or `mtls_thumbprint` | | `label` | string | No | Human-readable label | | `data` | object | Yes | Kind-specific data (see below) | **For `kind: "ip"`:** ```json theme={"dark"} { "value": "203.0.113.42" } ``` `value` must be a valid IPv4/IPv6 address or CIDR range. The stored `data` object also carries a `notes` string (empty unless set via the client's `ip_address` dual-write path), so credential responses for `kind: "ip"` return `data` as `{ "value": ..., "notes": ... }`. **For `kind: "mtls_thumbprint"` — issue a new certificate:** ```json theme={"dark"} { "mode": "issue" } ``` KnoxCall mints a certificate from your tenant CA, stores only the thumbprint, and returns the PEM bundle once in the `reveal` field. The private key is never stored. Because the response carries a private key, issuing is a reveal. On this API it needs a machine credential granted `client:create`: a token issued to a person (`knoxcall login`, a device or authorization-code grant) is refused with `403`, whatever the person's role. In the dashboard (a client's **Credentials** section, "Issue a new cert") only an **Owner** or an **Admin** can issue, and each issue asks for a fresh second factor (an authenticator code, a passkey or a security key) and writes a `plaintext.reveal` audit entry before the key is generated, the same as issuing from a [certificate authority](/essentials/pki/pki-overview). Registering an existing certificate's thumbprint (below) hands nothing back and needs neither. **For `kind: "mtls_thumbprint"` — register an existing certificate:** ```json theme={"dark"} { "sha256_thumbprint": "a1b2c3...64-hex-chars" } ``` `sha256_thumbprint` must be a SHA-256 hex digest. Non-hex characters are stripped before validation, and the remaining string must be exactly 64 hex characters or the request fails with `400 validation_error` (`data.sha256_thumbprint must be a SHA-256 hex digest (64 hex chars).`). ### Response ```json theme={"dark"} { "data": { "id": "cred-uuid-1", "client_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "tenant_id": "b2c3d4e5-f6a7-8901-bcde-f23456789012", "kind": "ip", "label": "Primary office", "data": { "value": "203.0.113.0/24", "notes": "" }, "enabled": true, "expires_at": null, "created_at": "2026-01-15T09:30:00.000Z", "updated_at": "2026-01-15T09:30:00.000Z", "last_matched_at": null }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` For `mtls_thumbprint` with `mode: "issue"`, the response also includes a one-time `reveal` field: ```json theme={"dark"} { "data": { "id": "cred-uuid-2", "client_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "tenant_id": "b2c3d4e5-f6a7-8901-bcde-f23456789012", "kind": "mtls_thumbprint", "data": { "sha256_thumbprint": "a1b2...", "subject": "CN=...", "serial": "...", "not_after": "2027-01-15T09:30:00Z" }, "enabled": true, "expires_at": "2027-01-15T09:30:00Z", "reveal": { "certificate_pem": "-----BEGIN CERTIFICATE-----\n...", "private_key_pem": "-----BEGIN PRIVATE KEY-----\n...", "ca_chain_pem": "-----BEGIN CERTIFICATE-----\n..." } }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` The `reveal.private_key_pem` is returned only once. Store it immediately — it is not retrievable again. ### Errors | Status | Type | Description | | - | - | - | | 400 | `validation_error` | Missing or invalid `kind`, or invalid `data` for the kind | | 404 | `not_found` | Client not found | *** ## Update Credential ```text theme={"dark"} PATCH /v1/clients/:id/credentials/:credId ``` Enables/disables a credential or updates its label. ### Path Parameters | Parameter | Type | Description | | - | - | - | | `id` | uuid | The client ID | | `credId` | uuid | The credential ID | ### Request Body At least one field is required. | Field | Type | Description | | - | - | - | | `enabled` | boolean | Enable or disable this credential | | `label` | string | Update the credential label | ### Response Returns the updated credential object. ### Errors | Status | Type | Description | | - | - | - | | 400 | `validation_error` | Neither `enabled` nor `label` provided | | 404 | `not_found` | Credential not found | *** ## Delete Credential ```text theme={"dark"} DELETE /v1/clients/:id/credentials/:credId ``` Removes a credential from a client. For mTLS thumbprint credentials, the underlying certificate is also revoked in the tenant CA's CRL. ### Path Parameters | Parameter | Type | Description | | - | - | - | | `id` | uuid | The client ID | | `credId` | uuid | The credential ID | ### Response ```json theme={"dark"} { "data": { "deleted": true }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ### Errors | Status | Type | Description | | - | - | - | | 404 | `not_found` | Credential not found | # Delete Client Source: https://docs.knoxcall.com/api-reference/clients/delete Permanently deletes a client and removes it from all route assignments ## Delete Client ```text theme={"dark"} DELETE /v1/clients/:id ``` Permanently deletes a client and removes it from all route assignments. ### Path Parameters | Parameter | Type | Description | | - | - | - | | `id` | uuid | The client ID | ### Response ```json theme={"dark"} { "data": { "deleted": true }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ### Errors | Status | Type | Description | | - | - | - | | 404 | `not_found` | Client not found | # Get Client Source: https://docs.knoxcall.com/api-reference/clients/get Returns a single client with its route assignments ## Get Client ```text theme={"dark"} GET /v1/clients/:id ``` Returns a single client with its route assignments. ### Path Parameters | Parameter | Type | Description | | - | - | - | | `id` | uuid | The client ID | ### Response ```json theme={"dark"} { "data": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "Production Backend", "type": "server", "ip_address": "203.0.113.0/24", "ip_notes": {}, "description": "Main backend servers", "enabled": true, "collection_id": null, "created_at": "2026-01-15T09:30:00.000Z", "updated_at": "2026-02-20T11:00:00.000Z", "route_assignments": [ { "route_id": "rt-uuid-1", "route_name": "Payment Gateway", "environment_name": "production" }, { "route_id": "rt-uuid-2", "route_name": "User Service", "environment_name": "production" } ] }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ### Errors | Status | Type | Description | | - | - | - | | 404 | `not_found` | Client not found | # List Clients Source: https://docs.knoxcall.com/api-reference/clients/list Returns a paginated list of all clients in your tenant ## List Clients ```text theme={"dark"} GET /v1/clients ``` Returns a paginated list of all clients in your tenant. ### Query Parameters | Parameter | Type | Default | Description | | - | - | - | - | | `page` | integer | `1` | Page number | | `per_page` | integer | `20` | Items per page (max 100) | | `sort` | string | `created_at` | Sort by: `created_at`, `name`, `type`, or `ip_address` | | `order` | string | `desc` | Sort direction: `asc` or `desc` | | `type` | string | -- | Filter by client type: `user` or `server` | ### Response ```json theme={"dark"} { "data": [ { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "Production Backend", "type": "server", "ip_address": "203.0.113.0/24", "ip_notes": {}, "description": "Main backend servers", "enabled": true, "collection_id": null, "created_at": "2026-01-15T09:30:00.000Z", "updated_at": "2026-02-20T11:00:00.000Z" } ], "meta": { "total": 8, "page": 1, "per_page": 20, "total_pages": 1, "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ```typescript Node.js theme={"dark"} import { KnoxCall } from "@knoxcall/sdk"; const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET const page = await client.clients.list({ page: 1, per_page: 20 }); const clients = page.data; ``` ```python Python theme={"dark"} from knoxcall import KnoxCall client = KnoxCall() # credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET page = client.clients.list(page=1, per_page=20) clients = page["data"] ``` ```bash cURL theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) curl "https://api.knoxcall.com/v1/clients?type=server" \ -H "Authorization: Bearer $TOKEN" ``` # Update Client Source: https://docs.knoxcall.com/api-reference/clients/update Updates one or more fields on an existing client ## Update Client ```text theme={"dark"} PATCH /v1/clients/:id ``` Updates one or more fields on an existing client. ### Path Parameters | Parameter | Type | Description | | - | - | - | | `id` | uuid | The client ID | ### Request Body All fields are optional. Only provided fields will be updated. | Field | Type | Description | | - | - | - | | `name` | string | Display name | | `type` | string | Client type: `user` or `server` | | `ip_address` | string | IP address or CIDR range | | `ip_notes` | object | IP metadata | | `description` | string | Description | | `enabled` | boolean | Enable or disable the client | ### Response Returns the updated client object. ### Errors | Status | Type | Description | | - | - | - | | 400 | `validation_error` | No valid fields provided or invalid type | | 404 | `not_found` | Client not found | | 409 | `conflict` | Another client in this mode already uses this name. Client names are unique per tenant per mode (production and sandbox are separate) | # Create Environment Source: https://docs.knoxcall.com/api-reference/environments/create Create a new environment for your tenant ## Create Environment ```text theme={"dark"} POST /v1/environments ``` Creates a new environment. ### Request Body | Field | Type | Required | Description | | - | - | - | - | | `name` | string | Yes | Machine-friendly name. Must match `^[a-z0-9_-]+$` (lowercase alphanumeric, hyphens, underscores) | | `display_name` | string | No | Human-friendly display name | | `description` | string | No | Description of the environment | | `color` | string | No | Hex color code for the UI (defaults to `#6366f1`) | | `is_default` | boolean | No | Whether this is the default environment (defaults to `false`) | ### Response Returns the created environment object. ```typescript Node.js theme={"dark"} import { KnoxCall } from "@knoxcall/sdk"; const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET const environment = await client.environments.create({ name: "staging", display_name: "Staging", description: "Pre-production testing", color: "#f59e0b" }); ``` ```python Python theme={"dark"} from knoxcall import KnoxCall client = KnoxCall() # credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET environment = client.environments.create( name="staging", display_name="Staging", description="Pre-production testing", color="#f59e0b" ) ``` ```bash cURL theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) curl -X POST https://api.knoxcall.com/v1/environments \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "staging", "display_name": "Staging", "description": "Pre-production testing", "color": "#f59e0b" }' ``` ### Errors | Status | Type | Description | | - | - | - | | 400 | `validation_error` | Invalid name format or missing required field | | 403 | `plan_limit` | Environments not available on your plan, or environment limit reached | | 409 | `conflict` | An environment with this name already exists | # Delete Environment Source: https://docs.knoxcall.com/api-reference/environments/delete Permanently delete an environment from your tenant ## Delete Environment ```text theme={"dark"} DELETE /v1/environments/:id ``` Permanently deletes an environment. You cannot delete the default environment or an environment that is in use by route configurations. ### Path Parameters | Parameter | Type | Description | | - | - | - | | `id` | uuid | The environment ID | ### Response ```json theme={"dark"} { "data": { "deleted": true }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ### Errors | Status | Type | Description | | - | - | - | | 400 | `validation_error` | Cannot delete the default environment | | 404 | `not_found` | Environment not found | | 409 | `in_use` | Environment is used by one or more route configurations | # List Environments Source: https://docs.knoxcall.com/api-reference/environments/list Returns all environments configured for your tenant ## List Environments ```text theme={"dark"} GET /v1/environments ``` Returns all environments for your tenant (not paginated — returns the full list). ### Response ```json theme={"dark"} { "data": [ { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "production", "display_name": "Production", "description": "Live production environment", "color": "#22c55e", "is_default": true, "created_at": "2026-01-01T00:00:00.000Z" }, { "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "name": "staging", "display_name": "Staging", "description": "Pre-production testing", "color": "#f59e0b", "is_default": false, "created_at": "2026-01-15T10:00:00.000Z" } ], "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ```typescript Node.js theme={"dark"} import { KnoxCall } from "@knoxcall/sdk"; const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET const environments = await client.environments.list(); // bare array — not paginated ``` ```python Python theme={"dark"} from knoxcall import KnoxCall client = KnoxCall() # credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET environments = client.environments.list() # bare list — not paginated ``` ```bash cURL theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) curl https://api.knoxcall.com/v1/environments \ -H "Authorization: Bearer $TOKEN" ``` # Update Environment Source: https://docs.knoxcall.com/api-reference/environments/update Update one or more fields on an existing environment ## Update Environment ```text theme={"dark"} PATCH /v1/environments/:id ``` Updates one or more fields on an existing environment. ### Path Parameters | Parameter | Type | Description | | - | - | - | | `id` | uuid | The environment ID | ### Request Body All fields are optional. Only provided fields will be updated. | Field | Type | Description | | - | - | - | | `name` | string | Machine-friendly name (must match `^[a-z0-9_-]+$`) | | `display_name` | string | Human-friendly display name | | `description` | string | Description | | `color` | string | Hex color code | | `is_default` | boolean | Set as default environment | ### Response Returns the updated environment object. ### Errors | Status | Type | Description | | - | - | - | | 400 | `validation_error` | Invalid name format | | 404 | `not_found` | Environment not found | | 409 | `conflict` | An environment with this name already exists | # Invoke Ephemeral Proxy Source: https://docs.knoxcall.com/api-reference/ephemeral-proxy/invoke One-shot proxy call that resolves vault tokens on the wire and forwards the request to an arbitrary HTTPS upstream. The Ephemeral Proxy is a single endpoint — `ANY /v1/proxy` — that accepts any HTTP method and forwards the request to the URL you supply in the `X-Knox-Proxy-URL` header. References of the form `{{ token: ... }}` in the body or query string are resolved against your tenant's vaults before the request reaches the upstream. For a conceptual overview see the [Ephemeral Proxy guide](/essentials/proxy/ephemeral-proxy). Unlike the [Routes](/api-reference/routes) endpoints — which manage proxy *configuration* — this endpoint **executes** a one-shot proxy call. There is no `id` to look up, no row to list, and no environment overrides. *** ## Invoke ```text theme={"dark"} ANY /v1/proxy ``` Forwards the request to the URL in `X-Knox-Proxy-URL`. Body and query-string `{{ token: ... }}` references are resolved against your tenant's vaults before the upstream call is made. The upstream response is streamed back unchanged. ### Request Headers | Header | Required | Description | | - | - | - | | `Authorization` | Yes | `Bearer $TOKEN` — a minted `kc_` OAuth access token (see [Authentication](/api-reference/authentication)). Legacy `tk_` / `AKE` API keys are also accepted as Bearer on this endpoint. | | `X-Knox-Proxy-URL` | Yes | The upstream URL. Must be `https://`, must use a DNS hostname (no IP literals), must pass SSRF validation. | | `Content-Type` | No | Defaults to `application/json` if omitted. Non-JSON bodies are sent as-is for string content. | | `X-Knox-Encrypted` | No | Base64-encoded JSON payload addressable from templates via `{{ encrypted \| json: $.path }}`. v1 is opaque base64-JSON; full JWE support is on the roadmap. | | `X-Knox-Timeout-Ms` | No | Per-request upstream timeout. Positive integer ≤ `30000`. Defaults to `25000`. | All other headers you send are forwarded to the upstream **except** for hop-by-hop headers (`Connection`, `Keep-Alive`, `TE`, `Trailers`, `Transfer-Encoding`, `Upgrade`, `Proxy-Authorization`, `Proxy-Authenticate`), the `Authorization`, `Host` and `Content-Length` headers, `Cookie`, and any header beginning with `X-Knox-`. Three more groups are never forwarded. Each name is matched exactly, in any letter case; apart from `X-Knox-` there is no prefix rule: * **The control headers KnoxCall reads itself:** `X-KnoxCall-Key`, `X-KnoxCall-Route`, `X-KnoxCall-Environment`, `X-KnoxCall-Signature`, `X-KnoxCall-Agent-Id`, `X-KnoxCall-Agent-Token`, `X-KnoxCall-Client-Assertion`, `X-KnoxCall-Origin` and `X-KnoxCall-Env`. * **The headers a reverse proxy adds to describe the connection to KnoxCall** rather than your request: `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Proto` and `X-Forwarded-Server`, `X-Real-IP`, `True-Client-IP`, `X-Client-Cert-Verify`, `X-Client-Cert-Thumbprint` and `X-Client-Cert-Subject`. * **The request headers Cloudflare adds:** `CF-Connecting-IP`, `CF-Ray`, `CF-IPCountry` and the rest of the exact names in [Request headers KnoxCall does not forward](/api-reference/request-headers), which is the complete list. A header that only resembles one of these is yours and is forwarded. That includes the `CF-Access-Client-Id` / `CF-Access-Client-Secret` pair and the `cf-access-token` header for an upstream behind Cloudflare Access. **Your KnoxCall credential is never forwarded.** `Authorization` and `Cookie` are always dropped, and `X-Api-Key` is dropped whenever it holds a KnoxCall credential (a value beginning `tk_`, `kc_`, `kr_` or `AKE`) — that is the key you authenticated *to KnoxCall* with, and sending it on would put a key granting your whole management API into your upstream's access logs. An `X-Api-Key` carrying your **upstream's** own key — `sk-ant-…` for Anthropic, and so on — is forwarded unchanged, so providers that authenticate with this header keep working. ### Request Body Optional. Sent to the upstream after token substitution. The body is scanned recursively for two template forms: | Template | Resolves to | | - | - | | `{{ token: tok_xxx }}` | The raw value stored under token `tok_xxx` in any of your tenant's vaults. | | `{{ encrypted \| json: $.path }}` | The value at JSONPath `$.path` inside the decoded `X-Knox-Encrypted` payload. | | `\{{ ... }}` | An escape — renders a literal `{{ ... }}` and is never resolved. | Anything else shaped like `{{ ... }}` is **rejected** with `400 template_error` rather than forwarded to the upstream. See [Templates](#templates). Maximum **20** distinct token references per request. ### Response The upstream's response is streamed back unchanged: same status code, same headers (minus hop-by-hop and `Content-Length`), same body bytes. KnoxCall adds two correlation headers: | Response Header | Description | | - | - | | `X-Knox-Request-Id` | The KnoxCall request ID. Use it to look up the audit log entry. | | `X-Knox-Destination-Status` | The upstream's status code, echoed in case an intermediate CDN rewrites it. | ```bash cURL theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) curl -X POST https://api.knoxcall.com/v1/proxy \ -H "Authorization: Bearer $TOKEN" \ -H "X-Knox-Proxy-URL: https://api.stripe.com/v1/charges" \ -H "Content-Type: application/json" \ -d '{ "amount": 5000, "currency": "usd", "source": "{{ token: tok_01HZX... }}" }' ``` ```python Python theme={"dark"} import os import requests # KNOXCALL_ACCESS_TOKEN is a minted OAuth access token — see /api-reference/authentication resp = requests.post( "https://api.knoxcall.com/v1/proxy", headers={ "Authorization": "Bearer " + os.environ["KNOXCALL_ACCESS_TOKEN"], "X-Knox-Proxy-URL": "https://api.stripe.com/v1/charges", "Content-Type": "application/json", }, json={ "amount": 5000, "currency": "usd", "source": "{{ token: tok_01HZX... }}", }, ) print(resp.status_code, resp.headers["X-Knox-Request-Id"]) print(resp.json()) ``` ```javascript Node.js theme={"dark"} // KNOXCALL_ACCESS_TOKEN is a minted OAuth access token — see /api-reference/authentication const resp = await fetch("https://api.knoxcall.com/v1/proxy", { method: "POST", headers: { Authorization: `Bearer ${process.env.KNOXCALL_ACCESS_TOKEN}`, "X-Knox-Proxy-URL": "https://api.stripe.com/v1/charges", "Content-Type": "application/json", }, body: JSON.stringify({ amount: 5000, currency: "usd", source: "{{ token: tok_01HZX... }}", }), }); console.log(resp.status, resp.headers.get("X-Knox-Request-Id")); console.log(await resp.json()); ``` ### Errors | Status | Type | Description | | - | - | - | | 400 | `validation_error` | Missing or malformed `X-Knox-Proxy-URL`, invalid `X-Knox-Timeout-Ms`, malformed `X-Knox-Encrypted`, or more than 20 token references. | | 400 | `token_not_found` | A `{{ token: ... }}` reference does not exist in any of your tenant's vaults. | | 400 | `token_expired` | A `{{ token: ... }}` reference names a token that exists but whose TTL has passed. Distinct from `token_not_found` so a lapsed TTL does not read as a token you never created. Nothing is substituted and nothing is sent upstream. | | 400 | `template_error` | A template expression failed to render (malformed JSONPath, missing key in encrypted payload), **or** is not one of the supported forms — e.g. `{{ secret: x }}`, `{{ token: }}`, or a mis-cased `{{ TOKEN: ... }}`. Escape it as `\{{` if the braces are meant for the upstream. | | 402 | `plan_limit` | Ephemeral Proxy requires the **Starter** plan or above. | | 403 | `forbidden` | The upstream URL failed SSRF validation (private IP range, metadata endpoint, non-HTTPS, etc.). | | 403 | `PolicyDenied` | RBAC denial — either the `ephemeral_proxy:invoke` gate or the per-token `vault:detokenize` gate denied the request. | | 403 | `insufficient_scope` | The OAuth access token was deliberately narrowed and its grant does not include `proxy:invoke`. Applies to every HTTP method — see [Permissions](#permissions). | | 409 | `pan_streaming_unsupported` | The request references a token from a **`pan`** vault *and* asks for a streamed response (`Accept: text/event-stream`, or `"stream": true` in the body). A streamed reply cannot be re-tokenized — a card number can straddle any chunk boundary the upstream picks — so the combination is refused rather than served unprotected. Send the card request without streaming. See [Responses are re-tokenized](/essentials/proxy/ephemeral-proxy#responses-are-re-tokenized). | | 502 | `response_not_retokenizable` | The request detokenized a **`pan`** token and the upstream's reply could not be scanned for the card number — it came back compressed (KnoxCall sends `Accept-Encoding: identity` on these requests; this one was ignored) or binary. The reply was **withheld** rather than forwarded unscanned. **Do not blindly retry** — the upstream call already happened. | | 500 | `internal_error` | A plan-limit lookup or token-resolution step failed internally — including a vault that cannot be opened (a key or KMS fault). The request is refused; the still-unresolved template is never forwarded. | | 502 | `upstream_error` | The upstream did not respond, returned an unrecoverable transport error, or timed out. | *** ## Templates ### `{{ token: }}` Substituted with the raw value of the matching vault token. Tokens are tenant-globally unique — you do not need to specify the vault. ```json theme={"dark"} { "card_number": "{{ token: tok_01HZX0M... }}", "exp_month": 12 } ``` If the token doesn't exist in any of your tenant's vaults, the request fails with `400 token_not_found`. If the token exists but you lack `vault:detokenize` permission for the vault that owns it, the request fails with `403` (`PolicyDenied`). If it exists but has expired, the request fails with `400 token_expired`. In every refusal the template is left unresolved and the upstream is not called — a token reference that cannot be opened never travels as itself. ### `{{ encrypted | json: $.path }}` Reads a value from the decoded `X-Knox-Encrypted` payload using JSONPath. Useful when you want to keep ad-hoc sensitive values out of your application's heap. ```bash theme={"dark"} # Encode the payload your application would otherwise put directly in the body PAYLOAD=$(echo -n '{"customer_id":"cus_123","cvc":"492"}' | base64) # $TOKEN is a minted OAuth access token — see /api-reference/authentication curl -X POST https://api.knoxcall.com/v1/proxy \ -H "Authorization: Bearer $TOKEN" \ -H "X-Knox-Proxy-URL: https://api.stripe.com/v1/payment_methods" \ -H "X-Knox-Encrypted: $PAYLOAD" \ -H "Content-Type: application/json" \ -d '{ "customer": "{{ encrypted | json: $.customer_id }}", "card": { "cvc": "{{ encrypted | json: $.cvc }}" } }' ``` In v1 the payload is opaque base64-encoded JSON; full JWE with per-tenant frontend keypairs is on the roadmap. ### `\{{ ... }}` — sending literal braces Any `{{ ... }}` that is not one of the two forms above is **rejected** with `400 template_error`. It is never forwarded. That is deliberate: silently passing an unrendered `{{ token: ... }}` through would disclose the token identifier to the third-party upstream and deliver a request with a hole where a credential should be. When the braces are meant for the **upstream's** own templating, escape the opening delimiter with a backslash. The escape renders a literal `{{`, and the text after it is never treated as a template — so a single body can carry both: ```json theme={"dark"} { "card_number": "{{ token: tok_01HZX0M... }}", "email_subject": "Hi \\{{ first_name }}, your receipt" } ``` The upstream receives `card_number` substituted with the real value, and `email_subject` as the literal `Hi {{ first_name }}, your receipt`. Note the doubled backslash: JSON has no `\{` escape, so a literal backslash is written `\\` in the source. Only the opening `{{` is escapable — a bare `}}` was never a delimiter and always passes through untouched, so nested JSON such as `{"a":{"b":{"c":1}}}}` is never rewritten. To bypass templating entirely and forward your bytes verbatim, send `X-Knox-Proxy-Mode: transparent` instead. *** ## Permissions Each invocation evaluates two permissions: | Resource | Action | When evaluated | | - | - | - | | `ephemeral_proxy` | `invoke` | Once per request, before URL validation. | | `vault` | `detokenize` | Per token reference, scoped to the vault that owns the token. | The `vault:detokenize` check is **enforced** per token reference, scoped to the vault that owns the token. A denial throws `PolicyDenied` and the request fails with `403`. ### OAuth scope These evaluate what the **credential** may do. If you call with an OAuth access token whose client was deliberately narrowed, a separate check evaluates what that **token** may do, and it requires `proxy:invoke` (or `proxy:*`) — **on every HTTP method**, because one handler serves them all and a bodyless `GET` spends an escrowed credential exactly as a `POST` does. `proxy:read` and `proxy:write` do not satisfy it: escrowing a credential is not permission to spend it. A refused call returns `403 insufficient_scope`, naming the scope in the `WWW-Authenticate` header and the body. API keys and un-narrowed tokens carry no grant to enforce and are unaffected. *** ## Audit Every invocation writes: * An `api_requests` row with `proxy_mode = 'ephemeral'`, status code, latency, upstream host, source IP, and token count. The body, headers, and resolved token values are **never** stored. * An `audit_logs` entry tagged `ephemeral_proxy.invoke` ([Audit Logs](/api-reference/audit-logs)). * A Fleet Graph signal carrying method, upstream host, status code, and latency. The customer's path is recorded as `/proxy` — never the raw upstream path. # Errors Source: https://docs.knoxcall.com/api-reference/errors The canonical KnoxCall error envelope, the full type-to-status table, and how to branch on error.type and correlate a request_id. # Errors Every failure on the KnoxCall Management API (`/v1`) returns **one canonical shape**. There is a single `error` object with a machine-readable `type`, a human-readable `message`, and a `request_id`. You branch on `type`; you show `message` to a human; you quote `request_id` to support. ## The error envelope ```json theme={"dark"} { "error": { "type": "not_found", "message": "Route not found", "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` | Field | Type | Description | | - | - | - | | `error.type` | string | Stable, machine-readable code. Branch on this — never on the HTTP status alone or on `message`. | | `error.message` | string | Human-readable explanation. Safe to surface to your users; may change wording over time. | | `error.request_id` | string (UUID) | The unique ID for this request. A **bare UUID** — never `req_`-prefixed. | The `error` object is the only top-level key on a failure response — there is no `data` or `meta` on an error. Success responses use `{ "data": ..., "meta": ... }`; failures use `{ "error": ... }`. Check for the presence of `error` first. ## Type → status table The HTTP status is derived from `error.type`. The mapping is stable — code against it. | Error Type | HTTP Status | Meaning | | - | - | - | | `authentication_required` | 401 | No API key was provided in the request | | `invalid_api_key` | 401 | The API key is invalid, revoked, or expired | | `wrong_key_type` | 403 | A test key was used against production, or vice versa | | `subscription_inactive` | 403 | The tenant's subscription is paused, cancelled, or past due | | `forbidden` | 403 | The key is valid but lacks the role/scope required for this action (RBAC denial) | | `validation_error` | 400 | The request body or query parameters failed validation | | `not_found` | 404 | The requested resource does not exist (or is not visible to this key) | | `method_not_allowed` | 405 | The request method is outside the set its group of endpoints supports, for example a `PATCH` to `/v1/api-keys/{id}`. The `Allow` response header lists the methods the group does support. Sent by `/v1/api-keys`, `/v1/audit-logs`, `/v1/logs`, `/v1/clients`, `/v1/environments`, `/v1/routes` and `/v1/webhooks`. No role or policy permission is checked for it, so it is the same for every credential | | `conflict` | 409 | A resource with the same unique identifier already exists | | `in_use` | 409 | The resource cannot be deleted because other resources depend on it | | `plan_limit` | 402 / 403 | The tenant has reached a limit imposed by their current plan | | `rate_limit_exceeded` | 429 | Too many requests — back off and retry (see [Rate limits](/api-reference/rate-limits)) | | `internal_error` | 500 | An unexpected server error occurred — contact support with the `request_id` | | `dependency_unavailable` | 503 | KnoxCall could not reach one of its own dependencies (`error.dependency` is `control_plane` or `rate_limit_store`) in time, so the request was refused rather than served from stale data or unmetered. Carries `Retry-After` (also echoed as `error.retry_after`, whole seconds). Safe to retry after that wait; **not** a credential problem — never re-authenticate on it. Answered on the data plane and the AI plane too, with the [response block](/api-reference/response-metadata) | | `sandbox_test_card_declined` | 402 | **Test data space only.** The request released a documented KnoxCall [test card](/essentials/vaults/overview#test-cards) that simulates a decline. Nothing was forwarded | | `sandbox_test_card_requires_action` | 402 | **Test data space only.** The request released the test card that simulates a 3-D Secure challenge. Nothing was forwarded | ### Idempotency error types Sent only on mutating requests that carry an idempotency key. See [Idempotency](/api-reference/idempotency) for the full flow. | Error Type | HTTP Status | Meaning | | - | - | - | | `invalid_idempotency_key` | 400 | The `X-Idempotency-Key` value is malformed or too long | | `request_in_progress` | 409 | A request with the same key is still being processed — retry shortly | | `idempotency_key_reuse` | 422 | The same key was reused with a **different** request body | `422` is used **only** for `idempotency_key_reuse`. Ordinary input-validation failures are always `400 validation_error` — never `422`. ## Branching on `error.type` Match on `error.type`, not on HTTP status codes and not on the human `message`. Statuses are shared across several types (three different types map to `403`), and `message` wording is not part of the contract. ```typescript Node.js theme={"dark"} // OAuth access token minted via POST /oauth/token — see /api-reference/authentication const resp = await fetch("https://api.knoxcall.com/v1/routes/does-not-exist", { headers: { Authorization: `Bearer ${process.env.KNOXCALL_ACCESS_TOKEN}` } }); if (!resp.ok) { const { error } = await resp.json(); switch (error.type) { case "not_found": // handle a missing resource break; case "rate_limit_exceeded": // read Retry-After and back off break; case "dependency_unavailable": // KnoxCall could not serve it: wait Retry-After, then resend — the request never ran break; case "invalid_api_key": case "authentication_required": // re-authenticate break; default: console.error(`KnoxCall error ${error.type}: ${error.message} (request ${error.request_id})`); } } ``` ```python Python theme={"dark"} # token: an OAuth access token minted via POST /oauth/token — see /api-reference/authentication resp = requests.get( "https://api.knoxcall.com/v1/routes/does-not-exist", headers={"Authorization": f"Bearer {token}"}, ) if resp.status_code >= 400: error = resp.json()["error"] if error["type"] == "not_found": ... # handle a missing resource elif error["type"] == "rate_limit_exceeded": ... # read Retry-After and back off elif error["type"] == "dependency_unavailable": ... # KnoxCall could not serve it: wait Retry-After, then resend else: raise RuntimeError( f"KnoxCall {error['type']}: {error['message']} " f"(request {error['request_id']})" ) ``` The first-party [SDKs](/sdks/overview) already map these types to typed exceptions (`NotFoundError`, `RateLimitError`, `AuthenticationError`, and so on) so you can `catch` them directly instead of inspecting raw responses. ## Correlating a request — `request_id` and `X-Request-Id` Every response carries the same request ID in two places: * **`error.request_id`** in the JSON body (on failures) and **`meta.request_id`** (on successes). * The **`X-Request-Id`** response header — present on **every** response, success or error. ```http theme={"dark"} HTTP/1.1 404 Not Found X-Request-Id: 550e8400-e29b-41d4-a716-446655440000 Content-Type: application/json { "error": { "type": "not_found", "message": "Route not found", "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` The header lets you record the ID from your HTTP client even when you never parsed the body (for example on a `500` you logged and moved past). The two values are always identical. When you contact support about a failed request, **quote the `request_id`**. It lets us trace the exact request through our systems — the upstream call, the tenant, the timing — without you having to reconstruct what happened. ## What's Next? Headers, `Retry-After`, and backoff guidance for `429` responses. Safe retries for mutating requests, replay semantics, and `409` / `422` behavior. Base URLs, response envelope, pagination, and quick examples. OAuth 2.1 + DPoP, CLI login, and API key types. # Idempotency Source: https://docs.knoxcall.com/api-reference/idempotency Retry mutating KnoxCall API requests safely with an idempotency key — replay semantics, and the 409 / 422 rules that keep retries from creating duplicates. # Idempotency Network calls fail in the worst way: the request reaches the server, the response is lost, and your client has no idea whether the operation happened. Retrying blindly risks creating the resource twice. An **idempotency key** makes a retry safe — KnoxCall recognises the repeated key and replays the original outcome instead of performing the operation again. Send an idempotency key on any **mutating** request (`POST`, `PUT`, `PATCH`, `DELETE`). It is ignored on safe `GET` requests, which are naturally idempotent. ## Sending an idempotency key Send a unique key per logical operation. A UUID is a good default. | Header | Notes | | - | - | | `X-Idempotency-Key` | The canonical KnoxCall spelling. | | `Idempotency-Key` | The standard spelling is **also accepted** — use whichever your HTTP client makes convenient. | ```bash theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) curl -X POST https://api.knoxcall.com/v1/secrets \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -H "X-Idempotency-Key: 3f8a9b2c-1d4e-4f6a-8b0c-2e1f0a3b4c5d" \ -d '{ "name": "STRIPE_API_KEY", "value": "sk_live_..." }' ``` Generate a **fresh** key for each distinct operation, and reuse the **same** key only when retrying that exact operation. Reusing one key for two genuinely different requests is an error — see [Reuse with a different body](#reuse-with-a-different-body-422). ## Replaying a stored response The first request with a given key is processed normally and its response is stored. Any later request with the **same key and the same body** does not re-run the operation — KnoxCall replays the stored status code and body, and adds a header so you can tell it was a replay: ```http theme={"dark"} HTTP/1.1 201 Created X-Idempotent-Replay: true X-Request-Id: 550e8400-e29b-41d4-a716-446655440000 Content-Type: application/json { "data": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "STRIPE_API_KEY" }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` `X-Idempotent-Replay: true` means "you are seeing a stored result, not a fresh one." Its absence means the request executed for real. The replayed body is byte-for-byte the original response, including its original status code. ## Error semantics Idempotency errors use the canonical [error envelope](/api-reference/errors). ### Still in progress → `409` If a request with the same key is **still being processed** when a retry arrives, the retry is rejected with `409` and the `request_in_progress` type. Wait briefly and retry — the original is on its way to completion. ```json theme={"dark"} { "error": { "type": "request_in_progress", "message": "A request with this idempotency key is still being processed.", "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ### Reuse with a different body → `422` If you reuse a key that was already used for a **different** request body, KnoxCall rejects the new request with `422` and the `idempotency_key_reuse` type rather than silently doing the wrong thing. This is the API's only `422`. The fix is to use a fresh key for the new operation. ```json theme={"dark"} { "error": { "type": "idempotency_key_reuse", "message": "This idempotency key was already used with a different request body.", "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ### Malformed key → `400` A key that is malformed or too long is rejected with `400` and the `invalid_idempotency_key` type before any work is done. ### Summary | Condition | Status | Error type | What to do | | - | - | - | - | | Same key, same body, original finished | 200 / 201 / … | — | You get the replayed response with `X-Idempotent-Replay: true` | | Same key, same body, original still running | 409 | `request_in_progress` | Wait briefly and retry | | Same key, **different** body | 422 | `idempotency_key_reuse` | Use a fresh key for the new operation | | Malformed / oversized key | 400 | `invalid_idempotency_key` | Send a well-formed key (a UUID works) | ## Recommended pattern 1. Generate one key per logical operation (a UUID) and hold onto it for the duration of your retries. 2. Send it as `X-Idempotency-Key` on the mutating request. 3. On a network error, timeout, or `5xx`, **retry with the same key** — you will either perform the operation once or replay its stored result. 4. On `409 request_in_progress`, wait briefly and retry the same key. 5. Never reuse a key for a different operation; if the body must change, mint a new key. The first-party [SDKs](/sdks/overview) attach an idempotency key to mutating requests automatically and reuse it across their internal retries, so safe retries work out of the box. ## What's Next? The canonical error envelope and the full type → status table. Headers, `Retry-After`, and backoff guidance for `429` responses. Base URLs, response envelope, pagination, and quick examples. OAuth 2.1 + DPoP, CLI login, and API key types. # Cancel Migration Source: https://docs.knoxcall.com/api-reference/migrations/cancel Cancel an in-flight migration, flipping its status to cancelled. ## POST /admin/migrations/:id/cancel Cancel an in-flight migration. Only valid for migrations in `pending`, `discovering`, or `awaiting_review` status. **Auth**: `requireOwnerOrAdmin` Cancellation flips the migration's `status` to `cancelled` (setting `completed_at`) and records a `migration.cancel` critical audit entry. It does **not** automatically tear down the discovery lease or any provisioned DB proxy routes — remove those separately via the `DELETE /admin/migrations/:id/db-proxy-routes/:routeId` endpoint. ### Response ```json theme={"dark"} { "id": "...", "status": "cancelled" } ``` Returns `404` if the migration is not found or its status is anything other than `pending`, `discovering`, or `awaiting_review` (for example `committing`, `failed`, `completed`, or already `cancelled`). ```bash theme={"dark"} curl -X POST https://api.knoxcall.com/admin/migrations/550e8400.../cancel \ -H "Authorization: Bearer $KC_ADMIN_JWT" ``` # Commit Migration Source: https://docs.knoxcall.com/api-reference/migrations/commit Write approved migration items into live KnoxCall secrets, one all-or-nothing batch per request. ## POST /admin/migrations/:id/commit Write `approved` items into live KnoxCall secrets. Each item creates or updates a secret and one `secret_environment_config` row per target environment. After commit the item's encrypted value is zeroed and its status becomes `committed`. **Large migrations commit in batches.** One request commits at most `batch_size` items (250) in a single transaction. If `remaining_count` in the response is greater than 0, the migration is left in `awaiting_review` and you issue the same request again to commit the next batch; already-committed items are never re-attempted, because the batch is selected by item status. An unbounded commit would exceed the 100-second edge timeout on large migrations, leaving the caller with a `524` and no report of what was written. **Auth**: `migration_reviewer` permission + **5-minute step-up** **Class B reveal (since 2026-10-05).** This action also passes the reveal chokepoint: the caller's own membership must be Owner or Admin (an Admin also needs the Owner-granted `migration_reviewer` delegation, checked first), the ONE `secret.migration` verification is consumed by the chokepoint itself (one verification per action), the per-person reveal budget is charged (60 per 5 minutes: `429 reveal_budget_exceeded`, or `503 reveal_budget_unavailable` when it cannot be counted), and a `plaintext.reveal` audit entry is written before anything is decrypted or changed (`503 reveal_audit_unavailable` if it cannot be). A missing verification answers `403` with `"code": "reveal_step_up_required"`, `"requires_step_up": true`, `"action_scope": "secret.migration"` and `"allowed_methods": ["passkey", "2fa"]`. The migration must be in `awaiting_review` status. If any item in a batch fails to write, that **whole batch** is rolled back and the migration reverts to `awaiting_review` so the operator can investigate and retry. Batches committed by earlier requests are durable and are not rolled back. A `migration.commit` entry is written to `audit_log_critical` after each successful batch, carrying `committed_count`, `remaining_count` and `batch_size`. ### Response ```json theme={"dark"} { "id": "550e8400-e29b-41d4-a716-446655440000", "status": "completed", "committed_count": 30, "remaining_count": 0, "batch_size": 250 } ``` `status` is `completed` only when `remaining_count` is `0`. While items remain it is `awaiting_review`: ```json theme={"dark"} { "id": "550e8400-e29b-41d4-a716-446655440000", "status": "awaiting_review", "committed_count": 250, "remaining_count": 2750, "batch_size": 250 } ``` Returns `409` if the migration is not in `awaiting_review` status, or if there are no approved items to commit. ```bash theme={"dark"} # /admin/* lives on the dashboard host (admin.knoxcall.com / any # knoxcall.com host), gated by your admin session JWT + X-Tenant-ID. # This route also requires a recent (within 5 min) second-factor # step-up with action_scope "secret.migration" — perform one on the same # session first (POST /auth/passkey/verify-step-up, /auth/2fa/verify-step-up, # or /auth/2fa/webauthn/verify-step-up for a security key), or this call # returns 403 with requires_step_up: true, action_scope "secret.migration" # and allowed_methods ["passkey", "2fa"]. An emailed code does not count, and # a verification made for any other action does not satisfy this gate. curl -X POST https://admin.knoxcall.com/admin/migrations/550e8400.../commit \ -H "Authorization: Bearer $KC_SESSION_JWT" \ -H "X-Tenant-ID: $KC_TENANT_ID" ``` # Create Migration Source: https://docs.knoxcall.com/api-reference/migrations/create Create a new migration / discovery job against an external secret store. ## POST /admin/migrations Create a new migration / discovery job. KnoxCall runs a live probe against the provider before persisting — if the federation grant is broken, the request fails immediately with `400` and nothing is stored. **Auth**: `requireOwnerOrAdmin` — no step-up required. ### Request body | Field | Required | Description | | - | - | - | | `provider` | ✅ | `aws_sm` \| `aws_ssm` \| `azure_kv` \| `gcp_sm` | | `mode` | ✅ | `pull` (server-side pull), `capture` (agent-side intercept), or `both` | | `credentials` | ✅ | Provider-specific federation parameters (no static keys — see below) | | `scope` | — | Provider-specific scope constraints (regions, vaults, etc.) | **`credentials` by provider:** | Provider | Required fields | | - | - | | `aws_sm` / `aws_ssm` | `role_arn` (IAM role ARN), optionally `external_id` | | `azure_kv` | `tenant_id` (Azure AD GUID), `client_id` (app registration GUID) — uses WIF, no client\_secret | | `gcp_sm` | `project_number`, `workload_identity_pool`, `workload_identity_provider`, `service_account_email` | Static credential fields (`access_key_id`, `secret_access_key`, `client_secret`, `private_key`, `service_account_json`, etc.) are **rejected at the API level** with `400`. ### Federation subject KnoxCall signs a short-lived OIDC assertion that your cloud provider validates against its trust policy. The `sub` claim is **specific to your tenant**: ``` knoxcall-migration: ``` Configure that exact value — Azure's federated-credential **Subject**, or a GCP WIF **attribute condition** such as `attribute.sub == "knoxcall-migration:"`. The New Migration page shows the correct string for the tenant you are signed into. Earlier connections were told to trust the bare constant `knoxcall-migration`, which was identical for every KnoxCall customer — any KnoxCall tenant's assertion satisfied a trust policy naming it. Existing migrations keep working on the old subject, but update your trust policy to the tenant-specific value. The assertion also carries a `knoxcall_tenant_id` claim you can gate on immediately if you would rather add an attribute condition than re-cut the subject. **`scope` by provider:** | Provider | Optional scope fields | | - | - | | `aws_sm` / `aws_ssm` | `regions` (array of region strings), `arn_allowlist` | | `azure_kv` | `subscription_id` (GUID), `vault_names` (array) — one of these is required | | `gcp_sm` | `project_ids` (array of GCP project ID strings) | ```bash AWS Secrets Manager theme={"dark"} curl -X POST https://admin.knoxcall.com/admin/migrations \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "X-Tenant-ID: $KC_TENANT_ID" \ -H "Content-Type: application/json" \ -d '{ "provider": "aws_sm", "mode": "pull", "credentials": { "role_arn": "arn:aws:iam::111122223333:role/KnoxCallMigrationReader", "external_id": "550e8400-e29b-41d4-a716-446655440000" }, "scope": { "regions": ["us-east-1", "eu-west-1"] } }' ``` ```bash Azure Key Vault theme={"dark"} curl -X POST https://admin.knoxcall.com/admin/migrations \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "X-Tenant-ID: $KC_TENANT_ID" \ -H "Content-Type: application/json" \ -d '{ "provider": "azure_kv", "mode": "pull", "credentials": { "tenant_id": "aaaabbbb-cccc-dddd-eeee-ffffgggghhhh", "client_id": "11112222-3333-4444-5555-666677778888" }, "scope": { "vault_names": ["my-prod-vault", "my-staging-vault"] } }' ``` ```bash GCP Secret Manager theme={"dark"} curl -X POST https://admin.knoxcall.com/admin/migrations \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "X-Tenant-ID: $KC_TENANT_ID" \ -H "Content-Type: application/json" \ -d '{ "provider": "gcp_sm", "mode": "pull", "credentials": { "project_number": "123456789012", "workload_identity_pool": "projects/123456789012/locations/global/workloadIdentityPools/knoxcall-pool", "workload_identity_provider": "projects/123456789012/locations/global/workloadIdentityPools/knoxcall-pool/providers/knoxcall-provider", "service_account_email": "migration-reader@my-project.iam.gserviceaccount.com" }, "scope": { "project_ids": ["my-gcp-project"] } }' ``` ### Response ```json theme={"dark"} { "id": "550e8400-e29b-41d4-a716-446655440000", "status": "pending", "note": "Migration enqueued. Poll GET /admin/migrations/:id for discovery progress." } ``` # DB Proxy Routes Source: https://docs.knoxcall.com/api-reference/migrations/db-proxy-routes Create, list, and delete wire-protocol proxy tunnels for migrated database credentials. ## DB Proxy Routes DB proxy routes are wire-protocol tunnels that let agents connect to a migrated database through KnoxCall using a `localhost:` listener. They can be created for any migration the reviewer owns — the `committed_secret_id` must reference an existing tenant secret, but no particular migration status is required. Routes are removed only via `DELETE /admin/migrations/:id/db-proxy-routes/:routeId`; cancelling the migration does not delete them. The write endpoints (`POST` and `DELETE`) require `migration_reviewer` permission + **5-minute step-up**. The `GET` (list) endpoint requires only tenant access to the migration — no `migration_reviewer` role or step-up. *** ## POST /admin/migrations/:id/db-proxy-routes Create a new wire-protocol proxy route for a migration. **Auth**: `migration_reviewer` + **5-minute step-up** ### Request body | Field | Required | Description | | - | - | - | | `protocol` | ✅ | Wire protocol: `postgres`, `mysql`, `mongodb`, `redis`, `sqlserver` | | `upstream_host` | ✅ | Hostname or IP of the real database | | `upstream_port` | ✅ | Port of the real database | | `committed_secret_id` | ✅ | ID of the committed KnoxCall secret holding the database credentials | | `credential_username` | ✅ | Database username to authenticate with. Required for every protocol **except** `redis` | | `local_port` | conditional | Agent-local port the tunnel listens on (must be unique per agent). Required when `bind_mode` is `tcp`; must be **1024–65535** (privileged ports under 1024 are not supported). Ignored — and stored as `null` — for the `unix` and `pipe` modes, which have no TCP port | | `bind_mode` | — | How the agent listener is exposed: `tcp` (default), `unix`, or `pipe` | | `unix_socket_path` | conditional | Absolute path (must start with `/`) for the listener. Required when `bind_mode` is `unix` | | `pipe_name` | conditional | Simple named-pipe name (no backslashes) for the listener. Required when `bind_mode` is `pipe` | | `upstream_database` | — | Database name to connect to | | `upstream_username` | — | Stored upstream username for display/reference | | `tls_mode` | — | `prefer` (default), `require`, `disable` | | `agent_id` | — | Scope the tunnel to a specific agent UUID; omit for any agent | | `allowed_uids` | — | Array of Unix UIDs allowed to use the tunnel | | `secret_migration_item_id` | — | Associate the route with a specific migration item | Each route binds exactly one listener, the one its `bind_mode` names: the other two listener fields are stored as `null`. Returns `409` if that listener — `local_port` for `tcp`, `unix_socket_path` for `unix`, `pipe_name` for `pipe` — is already in use by another enabled route for the same agent. The three are separate namespaces: a port never conflicts with a socket path. ### Response ```json theme={"dark"} { "id": "route-uuid" } ``` Status `201 Created`. *** ## GET /admin/migrations/:id/db-proxy-routes List all proxy routes for a migration. **Auth**: tenant access to the migration (no `migration_reviewer` role or step-up required) ### Response ```json theme={"dark"} { "routes": [ { "id": "route-uuid", "protocol": "postgres", "upstream_host": "prod-db.internal", "upstream_port": 5432, "upstream_database": "myapp", "upstream_username": "readonly", "tls_mode": "prefer", "local_port": 15432, "agent_id": null, "allowed_uids": [], "committed_secret_id": "secret-uuid", "secret_migration_item_id": "item-uuid", "enabled": true, "created_at": "2026-05-25T10:00:00Z", "updated_at": "2026-05-25T10:00:00Z" } ] } ``` *** ## DELETE /admin/migrations/:id/db-proxy-routes/:routeId Remove a proxy route. The agent tunnel is torn down immediately. **Auth**: `migration_reviewer` + **5-minute step-up** ### Response ```json theme={"dark"} { "id": "route-uuid", "deleted": true } ``` Returns `404` if the route is not found for this migration and tenant. # Get Migration Source: https://docs.knoxcall.com/api-reference/migrations/get Retrieve full details for a single migration, including a sample of its discovered items. ## GET /admin/migrations/:id Retrieve full details for a single migration, including a sample of its discovered items. ### Response ```json theme={"dark"} { "migration": { "id": "550e8400-e29b-41d4-a716-446655440000", "provider": "aws_sm", "mode": "pull", "status": "awaiting_review", "region": "us-east-1", "scope": { "regions": ["us-east-1"] }, "discovered_count": 42, "approved_count": 30, "rejected_count": 2, "committed_count": 0, "expired_count": 0, "denylisted_count": 1, "error_details": null, "created_at": "2026-05-20T09:00:00Z", "started_at": "2026-05-20T09:00:05Z", "completed_at": null }, "items": [ { "id": "item-uuid", "source_provider": "aws_sm", "source_id": "arn:aws:secretsmanager:us-east-1:111122223333:secret:stripe/prod-abc123", "source_path": "stripe/prod", "source_region": "us-east-1", "source_version": null, "source_metadata": {}, "bucket_classification": "a_https", "bucket_classification_source": "heuristic", "target_environments": ["production"], "status": "pending_review", "denylist_reason": null, "captured_at": "2026-05-20T09:00:30Z" } ] } ``` `bucket_classification` is `"a_https"` (HTTPS API secret) or `"b_wire"` (database credential). `bucket_classification_source` is `"heuristic"` (auto-detected) or `"user_override"` (reviewer changed it). Items are returned most-recent-first, limited to 200. For full pagination use the items sub-resource (coming in a future API version). # Approve Migration Item Source: https://docs.knoxcall.com/api-reference/migrations/items-approve Approve a migration item for import, optionally overriding target environments or bucket classification. ## POST /admin/migrations/:id/items/:itemId/approve Approve a migration item for import. Optionally override the target environments or bucket classification at approval time. **Auth**: `migration_reviewer` permission + **a recent step-up verification (5-minute window)** **Class B reveal (since 2026-10-05).** This action also passes the reveal chokepoint: the caller's own membership must be Owner or Admin (an Admin also needs the Owner-granted `migration_reviewer` delegation, checked first), the ONE `secret.migration` verification is consumed by the chokepoint itself (one verification per action), the per-person reveal budget is charged (60 per 5 minutes: `429 reveal_budget_exceeded`, or `503 reveal_budget_unavailable` when it cannot be counted), and a `plaintext.reveal` audit entry is written before anything is decrypted or changed (`503 reveal_audit_unavailable` if it cannot be). A missing verification answers `403` with `"code": "reveal_step_up_required"`, `"requires_step_up": true`, `"action_scope": "secret.migration"` and `"allowed_methods": ["passkey", "2fa"]`. Step-up is enforced server-side: the platform checks for a recent re-authentication by the signed-in user. No step-up token or header is sent on this request. To create a verification first, the user re-authenticates with a second factor, passing `"action_scope": "secret.migration"` in the body: `POST /auth/2fa/verify-step-up` (authenticator-app code), `POST /auth/passkey/verify-step-up` (passkey, challenge from `POST /auth/passkey/authenticate-options`), or `POST /auth/2fa/webauthn/verify-step-up` (security key or passkey, challenge from `POST /auth/2fa/webauthn/step-up-options`). An emailed code does not satisfy this gate. If no recent verification exists, this endpoint returns `403` with `{ "error": "Recent step-up verification required for this action", "requires_step_up": true, "max_age_minutes": 5, "action_scope": "secret.migration", "allowed_methods": ["passkey", "2fa"] }`. ### Request body (all optional) | Field | Type | Description | | - | - | - | | `target_environments` | string\[] | Override which environments to import into. Must not name the same environment twice — each environment is a single destination, so a repeat could only overwrite what the first mention wrote. | | `bucket_override` | `"a_https"` \| `"b_wire"` | Override the auto-detected bucket classification | ### Response ```json theme={"dark"} { "id": "item-uuid", "status": "approved" } ``` Returns `409` if the item is not in `pending_review` status. Returns `400` with `{ "error": "target_environments contains duplicate environment names" }` if `target_environments` names the same environment more than once. # Reject Migration Item Source: https://docs.knoxcall.com/api-reference/migrations/items-reject Reject a migration item and immediately zero its encrypted value. ## POST /admin/migrations/:id/items/:itemId/reject Reject a migration item. The item's encrypted value is immediately zeroed. **Auth**: `migration_reviewer` permission + **a recent step-up verification (5-minute window)** **Class B reveal (since 2026-10-05).** This action also passes the reveal chokepoint: the caller's own membership must be Owner or Admin (an Admin also needs the Owner-granted `migration_reviewer` delegation, checked first), the ONE `secret.migration` verification is consumed by the chokepoint itself (one verification per action), the per-person reveal budget is charged (60 per 5 minutes: `429 reveal_budget_exceeded`, or `503 reveal_budget_unavailable` when it cannot be counted), and a `plaintext.reveal` audit entry is written before anything is decrypted or changed (`503 reveal_audit_unavailable` if it cannot be). A missing verification answers `403` with `"code": "reveal_step_up_required"`, `"requires_step_up": true`, `"action_scope": "secret.migration"` and `"allowed_methods": ["passkey", "2fa"]`. Step-up is enforced server-side: the request carries no step-up token or header. The signed-in user must have completed a second-factor step-up within the last 5 minutes, passing `"action_scope": "secret.migration"` in the body: an authenticator-app code via `POST /auth/2fa/verify-step-up`, a passkey via `POST /auth/passkey/verify-step-up`, or a security key (or passkey) via `POST /auth/2fa/webauthn/step-up-options` then `POST /auth/2fa/webauthn/verify-step-up`. An emailed code does not satisfy this gate. If no recent verification exists, this endpoint returns `403 { "error": "Recent step-up verification required for this action", "requires_step_up": true, "max_age_minutes": 5, "action_scope": "secret.migration", "allowed_methods": ["passkey", "2fa"] }`. ### Request body | Field | Required | Description | | - | - | - | | `reason` | ✅ | Non-empty string (max 500 characters) explaining the rejection | ### Response ```json theme={"dark"} { "id": "item-uuid", "status": "rejected" } ``` Returns `409` if the item is not in `pending_review` status. # Reveal Item Value Source: https://docs.knoxcall.com/api-reference/migrations/items-reveal Reveal the plaintext or obfuscated value of a discovered migration item. ## GET /admin/migrations/:id/items/:itemId/value Reveal the plaintext (or obfuscated) value of a discovered migration item. **Auth**: `migration_reviewer` permission + **a recent step-up verification (5-minute window)** made with a second factor and `"action_scope": "secret.migration"` (an authenticator-app code via `POST /auth/2fa/verify-step-up`, a passkey via `POST /auth/passkey/verify-step-up`, or a security key via `POST /auth/2fa/webauthn/step-up-options` then `POST /auth/2fa/webauthn/verify-step-up`). An emailed code does not count, and a verification made for another action does not satisfy this gate; a missing one answers `403` with `"requires_step_up": true`, `"action_scope": "secret.migration"` and `"allowed_methods": ["passkey", "2fa"]`. **Class B reveal (since 2026-10-05).** This action also passes the reveal chokepoint: the caller's own membership must be Owner or Admin (an Admin also needs the Owner-granted `migration_reviewer` delegation, checked first), the ONE `secret.migration` verification is consumed by the chokepoint itself (one verification per action), the per-person reveal budget is charged (60 per 5 minutes: `429 reveal_budget_exceeded`, or `503 reveal_budget_unavailable` when it cannot be counted), and a `plaintext.reveal` audit entry is written before anything is decrypted or changed (`503 reveal_audit_unavailable` if it cannot be). A missing verification answers `403` with `"code": "reveal_step_up_required"`, `"requires_step_up": true`, `"action_scope": "secret.migration"` and `"allowed_methods": ["passkey", "2fa"]`. By default returns an obfuscated value (`first4••••last4` format). Append `?full=1` to receive the full plaintext — this is logged separately in the critical audit chain. Every call to this endpoint writes a `migration.item.reveal` entry to `audit_log_critical` (throwing — the reveal is refused if the audit write fails). The tenant owner receives an email notification when the reviewer is a different user. ### Response ```json theme={"dark"} { "id": "item-uuid", "value": "sk_live_••••••••••••••••••••••••••••••••Xz4q", "value_length": 51, "value_sha256_prefix": "a3f8c2e1b9d7f4a1", "mode": "obfuscated" } ``` With `?full=1`: ```json theme={"dark"} { "id": "item-uuid", "value": "sk_live_EXAMPLE_AbCdEfGhIjKlXz4q", "value_length": 32, "value_sha256_prefix": "a3f8c2e1b9d7f4a1", "mode": "full" } ``` # List Migrations Source: https://docs.knoxcall.com/api-reference/migrations/list List all secret store migration jobs for the tenant (most recent 100). ## GET /admin/migrations List all migrations for the tenant. Returns the most recent 100 migrations for the tenant. No query parameters are supported. ### Response ```json theme={"dark"} { "migrations": [ { "id": "550e8400-e29b-41d4-a716-446655440000", "provider": "aws_sm", "mode": "pull", "status": "awaiting_review", "region": "us-east-1", "discovered_count": 42, "approved_count": 30, "rejected_count": 2, "committed_count": 0, "expired_count": 0, "denylisted_count": 1, "error_details": null, "created_at": "2026-05-20T09:00:00Z", "started_at": "2026-05-20T09:00:05Z", "completed_at": null, "created_by": "user-uuid" } ] } ``` ```bash theme={"dark"} curl "https://admin.knoxcall.com/admin/migrations" \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "X-Tenant-ID: $KC_TENANT_ID" ``` # Verification Events Source: https://docs.knoxcall.com/api-reference/migrations/verification-events List verification probe events for a migration, and manually mark a migration or item as verified clean. ## GET /admin/migrations/:id/verification-events Returns verification probe events for a migration (most-recent first, up to 500), plus the current safe-to-delete state from the migration row. **No pagination** — the response is capped at 500 events. ### Response ```json theme={"dark"} { "migration_id": "550e8400-e29b-41d4-a716-446655440000", "safe_to_delete": false, "verified_at": null, "verification_window_days": 30, "events": [ { "id": "evt-uuid", "secret_migration_item_id": "item-uuid", "event_type": "operator_confirmed_clean", "endpoint_url": null, "outcome": "clean", "details": { "confirmed_by": "user-uuid", "notes": null }, "source": "manual", "created_at": "2026-05-25T10:25:00Z" } ] } ``` | Field | Description | | - | - | | `safe_to_delete` | `true` once the operator has confirmed the migration clean at the migration level | | `verified_at` | Timestamp of the first migration-level confirmation, or `null` | | `verification_window_days` | Days the system waits before allowing source-store deletion | | `events[].event_type` | e.g. `operator_confirmed_clean`, or system probe event types | | `events[].outcome` | `clean` or probe result | | `events[].source` | `manual` (operator) or `auto` (system probe) | *** ## POST /admin/migrations/:id/verification-events Manually mark a migration (or a single migration item) as verified clean. Requires `migration_reviewer` permission + **5-minute step-up**. When called without `secret_migration_item_id`, marks the entire migration as `safe_to_delete = true` and sets `verified_at` if not already set. ### Request body | Field | Required | Description | | - | - | - | | `secret_migration_item_id` | — | If provided, the confirmation is scoped to a single item. If omitted, confirms the entire migration. | | `notes` | — | Optional operator note (stored in `details.notes`) | ### Response ```json theme={"dark"} { "id": "evt-uuid" } ``` Status `201 Created`. Returns `403` if the caller lacks `migration_reviewer`. Returns `404` if the migration is not found. ```bash theme={"dark"} curl -X POST https://admin.knoxcall.com/admin/migrations/550e8400.../verification-events \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "X-Tenant-ID: $KC_TENANT_ID" \ -H "Content-Type: application/json" \ -d '{ "notes": "All secrets validated in production — safe to remove from AWS SM." }' ``` # API Overview Source: https://docs.knoxcall.com/api-reference/overview Learn how to authenticate and interact with the KnoxCall Management API # API Overview ## Introduction The KnoxCall REST API gives you full programmatic control over your API gateway configuration. You can manage routes, secrets, clients, environments, webhooks, and API keys -- everything available in the dashboard, accessible through a clean, predictable interface. All API endpoints return JSON and follow consistent conventions for authentication, pagination, error handling, and response structure. This API is for **managing** your KnoxCall configuration. To call the routes you've configured, send traffic to your tenant proxy URL (e.g., `https://{your-slug}.knoxcall.com/{your-route}`, or `https://sandbox-{your-slug}.knoxcall.com/{your-route}` in sandbox). ## Base URLs ```text theme={"dark"} https://api.knoxcall.com/v1 ``` Use live credentials — a minted `kc_live_` OAuth token, or a production API key (`tk_` / `AKE` prefixed). ```text theme={"dark"} https://sandbox.knoxcall.com/v1 ``` Use test credentials (`kc_test_` tokens or `tk_test_` keys) only. Data is isolated from production. Sandbox and production environments are completely isolated. API keys, routes, secrets, and all other resources created in one environment are not accessible from the other. ## Authentication Every request to the KnoxCall API must be authenticated. For new integrations we recommend one of the [first-party SDKs](/sdks/overview) — they handle OAuth 2.1 token minting, caching, refresh, and DPoP for you: ```typescript theme={"dark"} import { KnoxCall } from "@knoxcall/sdk"; const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET const routes = await client.routes.list(); ``` Calling the REST API directly, mint a short-lived OAuth 2.1 access token and pass it as a Bearer token. The curl examples on this page reuse `$TOKEN` from this mint: ```bash theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) curl https://api.knoxcall.com/v1/routes \ -H "Authorization: Bearer $TOKEN" ``` Long-lived API keys remain fully supported as a legacy alternative, passed in either header: | Method | Header | Example | | - | - | - | | Bearer token | `Authorization` | `Authorization: Bearer tk_live_abc123...` | | API key header | `x-api-key` | `x-api-key: tk_live_abc123...` | API keys are scoped per tenant and can be created in the [KnoxCall Dashboard](https://knoxcall.com) or via the [API Keys endpoint](/api-reference/api-keys). For creating an OAuth client, OAuth grant types, CLI login (`knoxcall login`), workload identity federation, key types, and security best practices, see the [Authentication guide](/api-reference/authentication). ## Response Format Every response from the KnoxCall API follows a consistent JSON structure. ### Successful Response (Single Resource) ```json theme={"dark"} { "data": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "Payment Gateway", "target_base_url": "https://api.stripe.com/v1", "enabled": true, "created_at": "2026-01-15T09:30:00Z" }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ### Successful Response (List) ```json theme={"dark"} { "data": [ { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "Payment Gateway", "enabled": true }, { "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "name": "User Service", "enabled": true } ], "meta": { "total": 100, "page": 1, "per_page": 20, "total_pages": 5, "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ### Error Response Every error on `/v1` returns the same canonical envelope — a single `error` object with a machine-readable `type`, a human-readable `message`, and a bare-UUID `request_id`: ```json theme={"dark"} { "error": { "type": "not_found", "message": "Route not found", "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` The `request_id` is a bare UUID (for example `550e8400-e29b-41d4-a716-446655440000`) — it is **not** prefixed with `req_`. The same value is returned on **every** response (success or error) in the `X-Request-Id` header, so you can correlate a log line even when you never parsed the body. Quote it when contacting support and we can trace the exact request through our systems. For the complete list of error types, how to branch on `error.type`, and correlation guidance, see the [Errors reference](/api-reference/errors). ## Pagination List endpoints support pagination via query parameters: | Parameter | Default | Max | Description | | - | - | - | - | | `page` | `1` | `floor(100000 / per_page) + 1` | Page number to retrieve | | `per_page` | `20` | `100` | Number of items per page | | `sort` | `created_at` | -- | Field to sort by | | `order` | `desc` | -- | Sort order: `asc` or `desc` | ```bash theme={"dark"} curl "https://api.knoxcall.com/v1/routes?page=2&per_page=50&sort=name&order=asc" \ -H "Authorization: Bearer $TOKEN" ``` The response `meta` object contains the total count and page information so you can build pagination controls: ```json theme={"dark"} { "meta": { "total": 237, "page": 2, "per_page": 50, "total_pages": 5, "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ### Depth limit Pagination is offset-based, and the server will not skip more than **100,000 rows**. `page` is clamped to `floor(100000 / per_page) + 1` — 1,001 pages at the maximum `per_page` of 100 — and `total_pages` is capped at the same value, so a loop that walks until `page >= total_pages` always terminates. `meta.page` is the **effective** page and always matches the rows you got back; if you ask for a deeper one you receive the last reachable page, not an error. `meta.total` keeps reporting the true row count, so when `total` is larger than `total_pages × per_page` you are past the depth limit. Narrow the result set with a filter — a date range, a status, a resource id — rather than paging further. ## Rate Limiting API requests are rate-limited **per API key**, enforced consistently across all API workers. When you exceed the limit you receive a `429` response with the `rate_limit_exceeded` error type. Rate-limit headers are returned on **every** response for which a limit is configured — not only on `429`s — so you can track your remaining budget as you go: | Header | Description | | - | - | | `X-RateLimit-Limit` | Maximum requests allowed in the current window | | `X-RateLimit-Remaining` | Requests remaining in the current window | | `X-RateLimit-Reset` | Epoch seconds (UTC) at which the current window resets | | `Retry-After` | Seconds to wait before retrying — sent **only** on `429` responses | If you receive a `429` response, back off and retry after the number of seconds indicated by the `Retry-After` header. Continuing to send requests during a rate limit will not reset the window. See the [Rate limits reference](/api-reference/rate-limits) for backoff guidance and worked examples. ## Error Types All errors return a consistent structure with a `type` field that you can use for programmatic error handling. | Error Type | HTTP Status | Description | | - | - | - | | `authentication_required` | 401 | No API key was provided in the request | | `invalid_api_key` | 401 | The API key is invalid, revoked, or expired | | `wrong_key_type` | 403 | A test key was used against production, or vice versa | | `subscription_inactive` | 403 | The tenant's subscription is paused, cancelled, or past due | | `forbidden` | 403 | The key is valid but lacks the role/scope required for this action (RBAC denial) | | `rate_limit_exceeded` | 429 | Too many requests -- back off and retry | | `validation_error` | 400 | The request body or parameters failed validation | | `not_found` | 404 | The requested resource does not exist | | `conflict` | 409 | A resource with the same unique identifier already exists | | `in_use` | 409 | The resource cannot be deleted because other resources depend on it | | `plan_limit` | 402 / 403 | The tenant has reached a limit imposed by their current plan | | `internal_error` | 500 | An unexpected server error occurred -- contact support | For `validation_error` responses, the `message` field contains a human-readable description of which field failed validation and why. Validation failures are always `400` — the only `422` on the API is `idempotency_key_reuse` (see [Idempotency](/api-reference/idempotency)). Mutating requests can also return idempotency errors — `invalid_idempotency_key` (400), `request_in_progress` (409), and `idempotency_key_reuse` (422). The complete type→status table, including these, lives in the [Errors reference](/api-reference/errors). ## Quick Examples ### List all routes ```bash cURL theme={"dark"} curl https://api.knoxcall.com/v1/routes \ -H "Authorization: Bearer $TOKEN" ``` ```python Python theme={"dark"} import os import requests resp = requests.get( "https://api.knoxcall.com/v1/routes", headers={"Authorization": f"Bearer {os.environ['KNOXCALL_ACCESS_TOKEN']}"} ) routes = resp.json()["data"] ``` ```javascript Node.js theme={"dark"} const resp = await fetch("https://api.knoxcall.com/v1/routes", { headers: { Authorization: `Bearer ${process.env.KNOXCALL_ACCESS_TOKEN}` } }); const { data: routes } = await resp.json(); ``` ### Create a secret ```bash cURL theme={"dark"} curl -X POST https://api.knoxcall.com/v1/secrets \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "STRIPE_API_KEY", "value": "sk_live_..." }' ``` ```python Python theme={"dark"} import os import requests resp = requests.post( "https://api.knoxcall.com/v1/secrets", headers={ "Authorization": f"Bearer {os.environ['KNOXCALL_ACCESS_TOKEN']}", "Content-Type": "application/json" }, json={ "name": "STRIPE_API_KEY", "value": "sk_live_..." } ) secret = resp.json()["data"] ``` ```javascript Node.js theme={"dark"} const resp = await fetch("https://api.knoxcall.com/v1/secrets", { method: "POST", headers: { Authorization: `Bearer ${process.env.KNOXCALL_ACCESS_TOKEN}`, "Content-Type": "application/json" }, body: JSON.stringify({ name: "STRIPE_API_KEY", value: "sk_live_..." }) }); const { data: secret } = await resp.json(); ``` ### Delete a client ```bash cURL theme={"dark"} curl -X DELETE https://api.knoxcall.com/v1/clients/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \ -H "Authorization: Bearer $TOKEN" ``` ```python Python theme={"dark"} import os import requests resp = requests.delete( "https://api.knoxcall.com/v1/clients/a1b2c3d4-e5f6-7890-abcd-ef1234567890", headers={"Authorization": f"Bearer {os.environ['KNOXCALL_ACCESS_TOKEN']}"} ) result = resp.json()["data"] # {"deleted": true} ``` ```javascript Node.js theme={"dark"} const resp = await fetch("https://api.knoxcall.com/v1/clients/a1b2c3d4-e5f6-7890-abcd-ef1234567890", { method: "DELETE", headers: { Authorization: `Bearer ${process.env.KNOXCALL_ACCESS_TOKEN}` } }); const { data } = await resp.json(); // { deleted: true } ``` ## What's Next? OAuth 2.1 + DPoP, CLI login, API key types, and security best practices. Create and manage your API proxy routes. Store and inject sensitive credentials into your routes. Control who can access your routes with API clients. # Rate Limits Source: https://docs.knoxcall.com/api-reference/rate-limits How the Management API rate-limits per API key, the response headers it returns, and how to back off correctly on a 429. # Rate Limits Requests to the KnoxCall Management API (`/v1`) are rate-limited **per API key**. The limit is enforced consistently across every API worker, so a burst spread across parallel connections counts against the same budget — there is no per-process loophole. This page covers the **Management API** (`api.knoxcall.com/v1`) — the control plane you use to configure routes, secrets, clients, and so on. Rate limits on your **proxy traffic** (the routes you expose to your own callers) are configured separately, per route / per client / per tenant — see [Rate Limiting overview](/essentials/rate-limiting/overview). ## Rate-limit headers When a limit is configured for a request, the response includes these headers — on **every** response, not only on a `429`, so you can watch your remaining budget as you go: | Header | Type | Description | | - | - | - | | `X-RateLimit-Limit` | integer | Maximum requests allowed in the current window | | `X-RateLimit-Remaining` | integer | Requests remaining in the current window | | `X-RateLimit-Reset` | integer | Epoch seconds (UTC) at which the current window resets and `Remaining` returns to `Limit` | | `Retry-After` | integer | Seconds to wait before retrying — sent on a `429` response, and on a `503 dependency_unavailable` (KnoxCall could not reach one of its own dependencies; see [Errors](/api-reference/errors)). Never on a success | A normal, well-under-budget response looks like this: ```http theme={"dark"} HTTP/1.1 200 OK X-RateLimit-Limit: 600 X-RateLimit-Remaining: 597 X-RateLimit-Reset: 1754390400 X-Request-Id: 550e8400-e29b-41d4-a716-446655440000 Content-Type: application/json ``` `X-RateLimit-Reset` is an **epoch-seconds** timestamp. Convert it to a delay with `reset - now()` (in seconds) if you want to sleep until the window rolls over rather than retrying blindly. ## The 429 response When you exceed the limit, the request is rejected with HTTP `429` and the canonical [error envelope](/api-reference/errors) using the `rate_limit_exceeded` type. The response also carries a `Retry-After` header telling you exactly how long to wait: ```http theme={"dark"} HTTP/1.1 429 Too Many Requests Retry-After: 4 X-RateLimit-Limit: 600 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1754390400 X-Request-Id: 7b0f2c9a-1d3e-4a5b-9c8d-2e1f0a3b4c5d Content-Type: application/json { "error": { "type": "rate_limit_exceeded", "message": "Rate limit exceeded. Retry after 4 seconds.", "request_id": "7b0f2c9a-1d3e-4a5b-9c8d-2e1f0a3b4c5d" } } ``` Continuing to send requests while rate-limited does **not** reset the window — it just burns effort. Wait for `Retry-After` seconds (or until `X-RateLimit-Reset`) before your next attempt. ## Backoff guidance 1. **Respect `Retry-After` first.** On a `429` it is the authoritative wait — sleep that many seconds before retrying. 2. **Watch `X-RateLimit-Remaining` proactively.** If it is trending toward `0`, slow down before you get a `429` rather than after. 3. **Add jitter.** When several workers hit the limit together, a fixed `Retry-After` makes them all retry in lockstep. Add a small random offset (e.g. `Retry-After + random(0, 1s)`) to spread the retries. 4. **Cap your retries.** Use exponential backoff with a ceiling and a maximum attempt count; surface a clear error to the caller rather than retrying forever. ```typescript Node.js theme={"dark"} async function withRetry(fn, maxAttempts = 5) { for (let attempt = 1; ; attempt++) { const resp = await fn(); if (resp.status !== 429) return resp; if (attempt >= maxAttempts) return resp; // give up, let the caller handle it const retryAfter = Number(resp.headers.get("Retry-After") ?? 1); const jitter = Math.random(); // 0–1s await new Promise((r) => setTimeout(r, (retryAfter + jitter) * 1000)); } } ``` ```python Python theme={"dark"} import random, time def with_retry(request, max_attempts=5): for attempt in range(1, max_attempts + 1): resp = request() if resp.status_code != 429: return resp if attempt == max_attempts: return resp # give up, let the caller handle it retry_after = int(resp.headers.get("Retry-After", "1")) time.sleep(retry_after + random.random()) # + jitter ``` The first-party [SDKs](/sdks/overview) implement `Retry-After`-aware backoff with jitter for you and raise a typed `RateLimitError` once retries are exhausted — you rarely need to hand- roll the loop above. ## What's Next? The canonical error envelope and the full type → status table. Retry mutating requests safely without creating duplicates. Per-route / per-client / per-tenant limits on your own proxy traffic. Base URLs, response envelope, pagination, and quick examples. # Request Headers KnoxCall Does Not Forward Source: https://docs.knoxcall.com/api-reference/request-headers The exact request headers KnoxCall withholds from your upstream on proxied traffic, and what to do if your upstream needs one of them # Request headers KnoxCall does not forward When KnoxCall proxies a request for you, a fixed set of request headers is withheld from your upstream. They describe the connection between your caller and KnoxCall, or they are instructions and credentials meant for KnoxCall itself, so they stop here. This page is the complete list of those headers. It applies to proxied traffic on every path: * your **routes**, in the cloud and on a self-hosted proxy * the **AI Gateway** (`/v1/ai/...`) * the **ephemeral proxy** (`/v1/proxy`) * **inbound-webhook forwards** (`hooks.knoxcall.com`) The match is on the **exact header name**, in any letter case. There is no prefix rule behind this list: a header that merely starts with `CF-`, `X-Forwarded-` or `X-KnoxCall-` and is not named below is not withheld by it. This page covers which headers are withheld. It is not a full description of how each path builds the request it sends to your upstream; that is on each path's own page. ## The list ### Headers that address KnoxCall These tell KnoxCall who is calling and which route to use. KnoxCall reads them and does not pass them on. | Header | What it is | | - | - | | `X-KnoxCall-Key` | Your KnoxCall API key | | `X-KnoxCall-API-Key` | The API key header of KnoxCall's interactive demo | | `X-KnoxCall-Route` | The route to call | | `X-KnoxCall-Environment` | The environment to use | | `X-KnoxCall-Signature` | The request signature, on a route that requires signed requests | | `X-KnoxCall-Agent-Id` | Agent credentials | | `X-KnoxCall-Agent-Token` | Agent credentials | | `X-KnoxCall-Client-Assertion` | A signed assertion KnoxCall's own services use to identify themselves | | `X-KnoxCall-Origin` | The marker the SDKs' route-aware interceptor sends | | `X-KnoxCall-Env` | An internal routing label KnoxCall's edge sets | | `Cookie` | The cookies a browser attached. On a KnoxCall host these include a signed-in KnoxCall session | ### Headers a proxy in front of KnoxCall adds A reverse proxy or CDN adds these to say where the request came from. They describe your caller's connection to KnoxCall, not the request your upstream should see. | Header | What it is | | - | - | | `X-Forwarded-For` | The caller's address | | `X-Forwarded-Host` | The host the caller used | | `X-Forwarded-Proto` | The scheme the caller used | | `X-Forwarded-Server` | The proxy's own name | | `X-Real-IP` | The caller's address | | `True-Client-IP` | The caller's address | | `X-Client-Cert-Verify` | Result of mutual-TLS client verification | | `X-Client-Cert-Thumbprint` | The client certificate's thumbprint | | `X-Client-Cert-Subject` | The client certificate's subject | ### Headers Cloudflare adds KnoxCall's cloud edge runs on Cloudflare. These are the request headers Cloudflare itself can add on the way in. | Header | What it is | | - | - | | `CF-Connecting-IP` | The caller's address | | `CF-Connecting-IPv6` | The caller's address | | `CF-Pseudo-IPv4` | The caller's address | | `CF-Connecting-O2O` | An edge routing marker | | `CF-Ray` | Cloudflare's request ID | | `CF-Visitor` | The scheme the caller used | | `CDN-Loop` | Loop detection | | `CF-Worker` | Edge routing detail | | `CF-EW-Via` | Edge routing detail | | `CF-IPCountry` | The caller's location | | `CF-IPCity` | The caller's location | | `CF-IPContinent` | The caller's location | | `CF-IPLatitude` | The caller's location | | `CF-IPLongitude` | The caller's location | | `CF-Region` | The caller's location | | `CF-Region-Code` | The caller's location | | `CF-Metro-Code` | The caller's location | | `CF-Postal-Code` | The caller's location | | `CF-Timezone` | The caller's location | | `CF-Bot-Score` | Cloudflare's bot assessment of the caller | | `CF-Verified-Bot` | Cloudflare's bot assessment of the caller | | `CF-JA3-Hash` | The caller's TLS fingerprint | | `CF-JA4` | The caller's TLS fingerprint | | `Exposed-Credential-Check` | Cloudflare's leaked-credentials check | | `Malicious-Uploads-Detection` | Cloudflare's upload scan | | `Cf-Access-Jwt-Assertion` | A Cloudflare Access token for a signed-in person | | `CF-Cert-Presented` | The caller's client certificate, as the edge saw it | | `CF-Cert-Verified` | The caller's client certificate, as the edge saw it | | `CF-Cert-Revoked` | The caller's client certificate, as the edge saw it | | `CF-Cert-Issuer-DN` | The caller's client certificate, as the edge saw it | | `CF-Cert-Subject-DN` | The caller's client certificate, as the edge saw it | | `CF-Cert-Issuer-DN-RFC2253` | The caller's client certificate, as the edge saw it | | `CF-Cert-Subject-DN-RFC2253` | The caller's client certificate, as the edge saw it | | `CF-Cert-Issuer-DN-Legacy` | The caller's client certificate, as the edge saw it | | `CF-Cert-Subject-DN-Legacy` | The caller's client certificate, as the edge saw it | | `CF-Cert-Serial` | The caller's client certificate, as the edge saw it | | `CF-Cert-Issuer-Serial` | The caller's client certificate, as the edge saw it | | `CF-Cert-SHA256` | The caller's client certificate, as the edge saw it | | `CF-Cert-SHA1` | The caller's client certificate, as the edge saw it | | `CF-Cert-Not-Before` | The caller's client certificate, as the edge saw it | | `CF-Cert-Not-After` | The caller's client certificate, as the edge saw it | | `CF-Cert-SKI` | The caller's client certificate, as the edge saw it | | `CF-Cert-Issuer-SKI` | The caller's client certificate, as the edge saw it | ### Headers that belong to one connection These describe a single network hop. KnoxCall opens its own connection to your upstream and sets what that connection needs itself. | Header | What it is | | - | - | | `Host` | Set from your upstream's URL | | `Content-Length` | Set from the body KnoxCall sends | | `Connection` | Hop-by-hop | | `Keep-Alive` | Hop-by-hop | | `Proxy-Authenticate` | Hop-by-hop | | `Proxy-Authorization` | Hop-by-hop | | `TE` | Hop-by-hop | | `Trailers` | Hop-by-hop | | `Transfer-Encoding` | Hop-by-hop | | `Upgrade` | Hop-by-hop | ## Each path also keeps back its own credentials The list above is shared by every path. Each path additionally keeps back the credential you used to authenticate to KnoxCall on that path: * **Routes.** `Authorization` and `DPoP` when the `Authorization` value is a KnoxCall access token (`Bearer kc_…` or `DPoP kc_…`), and `X-Api-Key` when it holds a KnoxCall credential. An `Authorization` or `X-Api-Key` that carries your upstream's own credential is forwarded. * **AI Gateway.** `Authorization`, `X-Api-Key`, `X-Knox-AI-Key` and `DPoP`. The gateway authenticates you with these and supplies your provider's credential from the route. * **Ephemeral proxy.** `Authorization`, every header beginning `X-Knox-`, and `X-Api-Key` when it holds a KnoxCall credential. See [Invoke the ephemeral proxy](/api-reference/ephemeral-proxy/invoke). * **Inbound-webhook forwards.** Every header beginning `X-Knox-`. KnoxCall sets those itself to tell your receiver whether the signature verified. A delivery whose signature did not verify, where you have chosen to forward those, carries `Content-Type` and KnoxCall's own `X-Knox-` headers and nothing else the sender sent. ## Cloudflare Access credentials for your own upstream are forwarded If your upstream sits behind Cloudflare Access, the headers your caller sends to authenticate to it are yours and are forwarded: `CF-Access-Client-Id`, `CF-Access-Client-Secret` and `cf-access-token`. Only the token Cloudflare Access adds on the way in to KnoxCall, `Cf-Access-Jwt-Assertion`, is withheld. ## If your upstream needs one of these headers There is no response header or error when a header is withheld. The request is forwarded without it. * **On a route, including the route behind an AI Gateway agent**, add the header to the route's injected headers. A header the route injects is sent to your upstream even when its name is in one of the first three tables above; the route's value is sent and the value your caller put in that header is not. There is no template helper for the caller's IP address, so this suits a fixed value. The connection-level headers in the last table are always set by KnoxCall. * **On the ephemeral proxy and on inbound-webhook forwards** there are no injected headers, so a header on this list cannot be sent to the upstream on those paths. Send the value under a header name that is not on this list. # The KnoxCall Response Block Source: https://docs.knoxcall.com/api-reference/response-metadata Tell a KnoxCall error from your provider's, and see exactly where the time went # The KnoxCall response block When you call a route through KnoxCall, the status, headers and body you get back are **your upstream's, forwarded verbatim**. That is deliberate: your code should see what your provider actually said, byte for byte. It leaves one question the response alone cannot answer. A `429` came back — was that your provider's rate limit, or ours? A `502` — did the call reach them at all? The KnoxCall response block answers it, on every response, without touching the body your provider sent. The whole `x-knox-` **response** namespace is reserved. If your upstream sends a header in it, KnoxCall drops it rather than relaying it. Everything below is always KnoxCall speaking, never something an upstream can forge. ## The one check that matters **`X-Knox-Upstream-Status` is present if and only if a real upstream response was received.** ```text theme={"dark"} # Your provider answered. This 429 is theirs. HTTP/1.1 429 Too Many Requests X-Knox-Origin: upstream X-Knox-Upstream-Status: 429 ← present # KnoxCall answered. The call never left our network. HTTP/1.1 429 Too Many Requests X-Knox-Origin: knoxcall X-Knox-Error: route_rate_limited ← no X-Knox-Upstream-Status ``` If you wrap a provider SDK, branch on that absence. Without it, a client library reads our `429` as the provider's and retries against a limit that is not the one refusing it — backing off in the wrong place, for the wrong reason, until the provider's own limiter finally trips. ```js theme={"dark"} const gatewayError = !res.headers.get('x-knox-upstream-status'); if (gatewayError) { // KnoxCall refused this. res.headers.get('x-knox-error') says why. // Do NOT hand it to the provider SDK's retry logic. } ``` `X-Knox-Origin` says the same thing in a word, for when you are reading `curl -i` rather than writing code. ## Every header | Header | Meaning | | - | - | | `X-Knox-Request-Id` | The call id. Quote it to support; it matches your logs. | | `X-Knox-Origin` | `upstream` or `knoxcall` — who produced this status and body. | | `X-Knox-Upstream-Status` | The status your upstream returned. **Present only on a genuine forward.** | | `X-Knox-Error` | KnoxCall's error code, on a KnoxCall-originated refusal. | | `X-Knox-Plane` | Which KnoxCall surface answered: `route`, `proxy`, `ai`. | | `X-Knox-Sandbox` | `true` when the call ran against your Test data space. | | `X-Knox-Latency-Ms` | Total milliseconds inside KnoxCall. | | `X-Knox-Latency-Phase` | What that number measured — see below. | | `X-Knox-Upstream-Ms` | Milliseconds spent waiting on your provider. | | `X-Knox-Overhead-Ms` | Milliseconds spent in KnoxCall itself. | | `X-Knox-RateLimit-Route` | `limit=…, remaining=…, reset=…` for the route limit. | | `X-Knox-RateLimit-Tenant` | The same, for your account-wide limit, plus `burst=…` — see below. | | `X-Knox-RateLimit-Monthly` | The same, for your plan's monthly call ceiling. | | `Server-Timing` | `knoxcall;dur=…, knoxcall_upstream;dur=…`, so your browser's network panel shows the split. Appended to any `Server-Timing` your upstream sent, never replacing it. | ## Latency, and what it measured `X-Knox-Latency-Ms` cannot mean the same thing on a buffered and a streamed response, so it is labelled rather than left ambiguous: * **`X-Knox-Latency-Phase: complete`** — the whole body was in hand. The number is the full round trip. * **`X-Knox-Latency-Phase: first-byte`** — the response is streaming. Headers must be sent before any body byte, so this is time-to-first-byte and the total is not knowable yet. `X-Knox-Upstream-Ms` and `X-Knox-Overhead-Ms` split that time between your provider and us. **Both are omitted when the split was not measured** rather than estimated — if you see them, they were timed. The upstream clock starts once your provider's address is resolved, so DNS lookup time counts as KnoxCall overhead, not provider time. The same split is stored on the request's log row. The log detail page's Proxy tab shows **Proxy Latency** as that overhead figure (total minus upstream), so what you read there and what the header said for the same request agree. ## Rate limits Each limiter reports its own window, so three different ceilings do not collapse into one ambiguous pair of numbers: ```text theme={"dark"} X-Knox-RateLimit-Route: limit=100, remaining=97, reset=2026-09-21T10:00:00.000Z X-Knox-RateLimit-Tenant: limit=10000, remaining=1583, reset=2026-09-21T10:00:01.000Z, burst=1667 X-Knox-RateLimit-Monthly: limit=100000, remaining=41233 ``` `reset` is an ISO-8601 instant. It is absent on the monthly window, which rolls over with your billing period rather than on a fixed clock. The account-wide (tenant) limit is a token bucket, so its window carries a fourth key, appended last so a parser of the first three is unaffected: * `limit` — your plan's sustained rate, in requests **per minute**. * `burst` — the most requests admitted **at once**: the bucket, ten seconds of `limit` on every plan (17 / 167 / 834 / 1,667 for Free / Starter / Pro / Enterprise). * `remaining` — requests you can send right now. Never more than `burst`. * `reset` — when the bucket is full again, so never more than about ten seconds away. A `429` from the tenant limit while you are under your per-minute `limit` means the burst ran out; `X-RateLimit-Layer: tenant` and `X-RateLimit-Burst` on that response say so. The older `X-RateLimit-Limit`, `X-RateLimit-Tenant-*` and `X-API-Usage-*` headers are still sent and still correct. They are deprecated in favour of the block above — the three families used different names, units and reset formats, and none of them said whether the limit that refused you was KnoxCall's or your provider's. Nothing that reads them today needs to change yet. ## On errors KnoxCall itself returns When KnoxCall refuses a call, the body is ours, so the block is repeated there as a `knoxcall` object alongside the usual `error` envelope: ```json theme={"dark"} { "error": { "type": "route_rate_limited", "message": "Route rate limit exceeded. Try again in 12 seconds.", "request_id": "0f9c1a52-…" }, "knoxcall": { "request_id": "0f9c1a52-…", "origin": "knoxcall", "plane": "route", "latency_ms": 3, "latency_phase": "complete", "code": "route_rate_limited", "message": "Route rate limit exceeded. Try again in 12 seconds.", "rate_limit": { "route": { "limit": 100, "remaining": 0, "reset": "2026-09-21T10:00:00.000Z" } } } } ``` The `knoxcall` key is a sibling of `error`, not nested inside it, so the existing error envelope is unchanged and a client that does not know about the block simply ignores it. ### When KnoxCall itself could not serve the call If a KnoxCall dependency was unreachable or did not answer in time, the call is refused rather than forwarded unmetered or served from stale data: ```text theme={"dark"} HTTP/1.1 503 Service Unavailable Retry-After: 5 X-Knox-Origin: knoxcall X-Knox-Error: dependency_unavailable ``` ```json theme={"dark"} { "error": { "type": "dependency_unavailable", "message": "KnoxCall could not reach its control plane in time. The request was not forwarded; retry after the Retry-After interval.", "request_id": "0f9c1a52-…", "dependency": "control_plane", "retry_after": 5 }, "knoxcall": { "request_id": "0f9c1a52-…", "origin": "knoxcall", "plane": "route", "code": "dependency_unavailable", "…": "…" } } ``` `dependency` is `control_plane` or `rate_limit_store`. Treat it like any other 503: wait `Retry-After`, then retry — the request never reached your provider, so it is safe to resend. It is not a credential problem and it is not a rate-limit hit; do not refresh credentials or back off against your own limits because of it. The official SDKs already do this. On a **forwarded** response the block stays in the headers only. Your upstream's body is passed through untouched — it may be JSON, a PDF, an image or a stream, and KnoxCall does not rewrite any of them. # Create Route Source: https://docs.knoxcall.com/api-reference/routes/create Creates a new route in your tenant ## Create Route ```text theme={"dark"} POST /v1/routes ``` Creates a new route in your tenant. ### Request Body | Field | Type | Required | Description | | - | - | - | - | | `name` | string | Yes | Display name for the route | | `target_base_url` | string | Yes | The upstream API base URL to proxy requests to | | `base_environment` | string | No | Default environment (defaults to `production`) | | `inject_headers_json` | object | No | Headers to inject into proxied requests | | `inject_body_json` | object | No | Body fields to inject into proxied requests | | `intercept_enabled` | boolean | No | Opt the base environment into [route-aware interception](/essentials/routes/route-aware-interception): SDK interceptors and the agent's intercept mode send matching third-party traffic through this route. Default `false`. Refused with `409 intercept_route_conflict` when another intercept-enabled route already covers the same upstream host and base path in that environment. | | `injection_rules` | object\[] | No | **Removed.** A non-empty value is rejected with 400 — it was stored and applied to nothing. Use `{{secret_id:}}` / `{{secret:}}` placeholders in the request body your client sends, for a secret the route's own templates already send ([which secrets a client may name](/essentials/secrets/using-secrets#which-secrets-your-client-may-name)). An empty array is still accepted as a no-op. | | `ip_allowlist` | string\[] | No | IP addresses or CIDR ranges allowed to call this route | | `method_configs` | object | No | Per-HTTP-method configuration overrides | | `collection_id` | string | No | Assign to a collection | ### Response Returns the newly created route record (the full `routes` table row, including all columns). Note this differs from the [Get Route](./get) response, which returns a projected shape with additional derived fields (`environment_override_count`, `payload_structure`, `injection_rules`, `ip_allowlist`, `data_plane_node_id`, and `configured_environments`) that are not included here. Call [Get Route](./get) to retrieve those fields. ```typescript Node.js theme={"dark"} import { KnoxCall } from "@knoxcall/sdk"; const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET const route = await client.routes.create({ name: "Payment Gateway", target_base_url: "https://api.stripe.com/v1" }); ``` ```python Python theme={"dark"} from knoxcall import KnoxCall client = KnoxCall() # credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET route = client.routes.create( name="Payment Gateway", target_base_url="https://api.stripe.com/v1" ) ``` ```bash cURL theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) curl -X POST https://api.knoxcall.com/v1/routes \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Payment Gateway", "target_base_url": "https://api.stripe.com/v1" }' ``` ### Errors | Status | Type | Description | | - | - | - | | 400 | `validation_error` | Missing required fields or invalid environment | | 403 | `plan_limit` | Route limit reached for your subscription plan | | 409 | `conflict` | A route with this name already exists | # Delete Route Source: https://docs.knoxcall.com/api-reference/routes/delete Permanently deletes a route and all of its environment overrides ## Delete Route ```text theme={"dark"} DELETE /v1/routes/:id ``` Permanently deletes a route and all of its environment overrides. ### Path Parameters | Parameter | Type | Description | | - | - | - | | `id` | uuid | The route ID | ### Response ```json theme={"dark"} { "data": { "deleted": true }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ### Errors | Status | Type | Description | | - | - | - | | 404 | `not_found` | Route not found | # Route Environments Source: https://docs.knoxcall.com/api-reference/routes/environments List, set, and delete environment-specific configuration overrides for a route ## List Route Environments ```text theme={"dark"} GET /v1/routes/:id/environments ``` Returns all environment-specific configuration overrides for a route. ### Path Parameters | Parameter | Type | Description | | - | - | - | | `id` | uuid | The route ID | ### Response ```json theme={"dark"} { "data": [ { "environment_name": "staging", "target_base_url": "https://api-staging.stripe.com/v1", "inject_headers_json": {}, "inject_body_json": {}, "intercept_enabled": false, "require_signature": false, "signature_tolerance_sec": null, "rate_limit_enabled": false, "rate_limit_requests": null, "rate_limit_window_sec": null, "rate_limit_burst": null, "allowed_methods": null } ], "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ### Errors | Status | Type | Description | | - | - | - | | 404 | `not_found` | Route not found | *** ## Set Route Environment Override ```text theme={"dark"} PUT /v1/routes/:id/environments/:env ``` Creates or replaces the environment-specific configuration for a route. This lets you point the same route at a different upstream URL, inject different headers, or apply different rate limits per environment. ### Path Parameters | Parameter | Type | Description | | - | - | - | | `id` | uuid | The route ID | | `env` | string | Environment name (e.g., `staging`, `development`) | ### Request Body All fields are optional. Only provided fields will override the base route configuration for this environment; a field you omit keeps its stored value. `target_base_url` is required when the override does not exist yet (`400 validation_error` otherwise). | Field | Type | Description | | - | - | - | | `target_base_url` | string | Override upstream URL | | `inject_headers_json` | object | Override injected headers | | `inject_body_json` | object | Override injected body fields | | `intercept_enabled` | boolean | Opt this environment into [route-aware interception](/essentials/routes/route-aware-interception). Turning it on is refused with `409 intercept_route_conflict` when another intercept-enabled route already covers the same upstream host and base path in this environment. | | `require_signature` | boolean | Override signature requirement | | `signature_tolerance_sec` | number | Override signature tolerance | | `rate_limit_enabled` | boolean | Override rate limiting | | `rate_limit_requests` | number | Override max requests | | `rate_limit_window_sec` | number | Override window duration | | `rate_limit_burst` | number | Override burst allowance | | `allowed_methods` | string\[] | Override allowed HTTP methods | ### Response Returns the environment config object. ### Errors | Status | Type | Description | | - | - | - | | 400 | `validation_error` | Invalid environment name | | 403 | `forbidden` | The route carries a standing `decrypt` or `detokenize` field action and this write would give it a destination it does not already store, or switch on one it stores but does not send to: a token issued to a person is refused whatever the role, and a key must hold the action's own permission (`transit:decrypt` / `vault:detokenize`). See [Moving a route that decrypts or detokenizes](/essentials/routes/creating-routes#moving-a-route-that-decrypts-or-detokenizes). | | 403 | `insufficient_scope` | The same, for an OAuth token narrowed to scopes that do not include the action's own (`transit:decrypt` / `vaults:detokenize`); the `WWW-Authenticate` header names it. | | 404 | `not_found` | Route not found | *** ## Delete Route Environment Override ```text theme={"dark"} DELETE /v1/routes/:id/environments/:env ``` Removes the environment-specific override, reverting the route to its base configuration for that environment. ### Path Parameters | Parameter | Type | Description | | - | - | - | | `id` | uuid | The route ID | | `env` | string | Environment name | ### Response ```json theme={"dark"} { "data": { "deleted": true }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ### Errors | Status | Type | Description | | - | - | - | | 404 | `not_found` | Route or environment config not found | # Get Route Source: https://docs.knoxcall.com/api-reference/routes/get Returns the full configuration for a single route ## Get Route ```text theme={"dark"} GET /v1/routes/:id ``` Returns the full configuration for a single route, including injection rules, environment overrides, and payload structure. `payload_structure` is the base environment's saved sample request body as its **structure only**: the same keys and nesting, with every value replaced by a type placeholder (`""`, `""`, `""`; `null` stays `null`). The sample's values are never returned by the API — a saved sample is usually a captured request, so its values are a recorded reveal for an Admin or the Owner in the dashboard. ### Path Parameters | Parameter | Type | Description | | - | - | - | | `id` | uuid | The route ID | ### Response ```json theme={"dark"} { "data": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "Payment Gateway", "target_base_url": "https://api.stripe.com/v1", "base_environment": "production", "enabled": true, "requires_clients": false, "methods": null, "mtls_certificate_id": null, "collection_id": null, "created_at": "2026-01-15T09:30:00.000Z", "environment_override_count": 2, "payload_structure": { "order": { "id": "", "amount": "", "paid": "" } }, "injection_rules": null, "ip_allowlist": null, "data_plane_node_id": null, "configured_environments": ["production", "staging"] }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ### Errors | Status | Type | Description | | - | - | - | | 404 | `not_found` | Route not found | # List Routes Source: https://docs.knoxcall.com/api-reference/routes/list Returns a paginated list of all routes in your tenant ## List Routes ```text theme={"dark"} GET /v1/routes ``` Returns a paginated list of all routes in your tenant. ### Query Parameters | Parameter | Type | Default | Description | | - | - | - | - | | `page` | integer | `1` | Page number | | `per_page` | integer | `20` | Items per page (max 100) | | `sort` | string | `created_at` | Sort by: `created_at`, `name`, or `enabled` | | `order` | string | `desc` | Sort direction: `asc` or `desc` | | `enabled` | boolean | -- | Filter by enabled status | ### Response ```json theme={"dark"} { "data": [ { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "Payment Gateway", "target_base_url": "https://api.stripe.com/v1", "base_environment": "production", "enabled": true, "requires_clients": false, "collection_id": null, "created_at": "2026-01-15T09:30:00.000Z", "environment_override_count": 2, "require_signature": false, "rate_limit_enabled": true, "rate_limit_requests": 100, "rate_limit_window_sec": 60, "allowed_methods": ["GET", "POST"] } ], "meta": { "total": 24, "page": 1, "per_page": 20, "total_pages": 2, "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ```typescript Node.js theme={"dark"} import { KnoxCall } from "@knoxcall/sdk"; const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET const page = await client.routes.list({ page: 1, per_page: 20 }); const routes = page.data; ``` ```python Python theme={"dark"} from knoxcall import KnoxCall client = KnoxCall() # credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET page = client.routes.list(page=1, per_page=20) routes = page["data"] ``` ```bash cURL theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) curl "https://api.knoxcall.com/v1/routes?page=1&per_page=20" \ -H "Authorization: Bearer $TOKEN" ``` # Get Route Logs Source: https://docs.knoxcall.com/api-reference/routes/logs Returns paginated request logs for a specific route ## Get Route Logs ```text theme={"dark"} GET /v1/routes/:id/logs ``` Returns paginated request logs for a specific route, showing every proxied request with status, latency, and geographic data. ### Path Parameters | Parameter | Type | Description | | - | - | - | | `id` | uuid | The route ID | ### Query Parameters | Parameter | Type | Default | Description | | - | - | - | - | | `page` | integer | `1` | Page number | | `per_page` | integer | `20` | Items per page (max 100) | ### Response ```json theme={"dark"} { "data": [ { "id": "log-uuid", "request_id": "req_a1b2c3d4", "ts": "2026-03-10T14:22:01.000Z", "src_ip": "203.0.113.42", "method": "POST", "path": "/v1/charges", "status_code": 200, "latency_ms": 187, "upstream_host": "api.stripe.com", "error": null, "environment": "production", "rate_limited": false, "signature_valid": true, "source_ip_country": "US", "source_ip_city": "San Francisco" } ], "meta": { "total": 1482, "page": 1, "per_page": 20, "total_pages": 75, "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ### Errors | Status | Type | Description | | - | - | - | | 404 | `not_found` | Route not found | # Update Route Source: https://docs.knoxcall.com/api-reference/routes/update Updates one or more fields on an existing route ## Update Route ```text theme={"dark"} PATCH /v1/routes/:id ``` Updates one or more fields on an existing route. Only include the fields you want to change. ### Path Parameters | Parameter | Type | Description | | - | - | - | | `id` | uuid | The route ID | ### Request Body All fields are optional. Only provided fields will be updated. | Field | Type | Description | | - | - | - | | `name` | string | Display name | | `enabled` | boolean | Enable or disable the route | | `requires_clients` | boolean | Whether requests must come from an authorized client | | `target_base_url` | string | Upstream API base URL | | `inject_headers_json` | object | Headers to inject | | `inject_body_json` | object | Body fields to inject | | `intercept_enabled` | boolean | Per-environment field (the base environment): opt into [route-aware interception](/essentials/routes/route-aware-interception). Turning it on is refused with `409 intercept_route_conflict` when another intercept-enabled route already covers the same upstream host and base path in that environment. | | `method_configs` | object | Per-HTTP-method configuration | | `injection_rules` | object\[] | **Removed.** A non-empty value is rejected with 400 — it was stored and applied to nothing. Use `{{secret_id:}}` / `{{secret:}}` placeholders in the request body your client sends, for a secret the route's own templates already send ([which secrets a client may name](/essentials/secrets/using-secrets#which-secrets-your-client-may-name)). An empty array is still accepted as a no-op. | | `require_signature` | boolean | Require request signature validation | | `signature_tolerance_sec` | number | Signature timestamp tolerance in seconds | | `rate_limit_enabled` | boolean | Enable rate limiting | | `rate_limit_requests` | number | Max requests per window | | `rate_limit_window_sec` | number | Rate limit window in seconds | | `rate_limit_burst` | number | Burst allowance | | `allowed_methods` | string\[] | Restrict to specific HTTP methods | | `http_method_restrictions_enabled` | boolean | Enable method restrictions | | `egress_server_id` | string | Route through a specific egress/VPN server | | `ip_allowlist` | string\[] | Allowed IP addresses or CIDR ranges | | `data_plane_node_id` | string | Assign to a specific data plane node | ### Response Returns the updated route object. ### Errors | Status | Type | Description | | - | - | - | | 400 | `validation_error` | No valid fields provided | | 403 | `forbidden` | The route carries a standing `decrypt` or `detokenize` field action and this write would give it a destination it does not already store, or switch on one it stores but does not send to: a token issued to a person is refused whatever the role, and a key must hold the action's own permission (`transit:decrypt` / `vault:detokenize`). See [Moving a route that decrypts or detokenizes](/essentials/routes/creating-routes#moving-a-route-that-decrypts-or-detokenizes). | | 403 | `insufficient_scope` | The same, for an OAuth token narrowed to scopes that do not include the action's own (`transit:decrypt` / `vaults:detokenize`); the `WWW-Authenticate` header names it. | | 404 | `not_found` | Route not found | # SCIM 2.0 Provisioning Source: https://docs.knoxcall.com/api-reference/scim Connect Microsoft Entra ID, Okta or any RFC 7644 client to KnoxCall: base URL, token issuance, supported resources and filters, the attributes we accept but never store, the Enterprise requirement, and the rate limits your connector must honour. # SCIM 2.0 Provisioning KnoxCall implements [RFC 7644](https://www.rfc-editor.org/rfc/rfc7644) (SCIM Protocol) and [RFC 7643](https://www.rfc-editor.org/rfc/rfc7643) (SCIM Schema) so your identity provider can create, update and deprovision workspace members — and OAuth clients — without anyone logging into KnoxCall. This is the page your connector's `documentationUri` points at. If you arrived here from `GET /scim/v2/ServiceProviderConfig`, you are in the right place. **SCIM provisioning requires the Enterprise plan.** Discovery (`/ServiceProviderConfig`, `/Schemas`, `/ResourceTypes`) is served on every plan so that a connector can be configured and can read this URL; the `/Users` and `/OAuthClients` endpoints are served to Enterprise workspaces. See [Plan requirement](#plan-requirement) for exactly what a workspace sees when the plan does not cover it. ## Base URL The SCIM service lives under `/scim/v2` on your workspace's KnoxCall control-plane host: ``` https://.knoxcall.com/scim/v2 ``` `https://admin.knoxcall.com/scim/v2` works too. Requests that arrive on any other hostname — including directly to the origin address — are answered `404` before authentication is attempted, so paste the workspace URL exactly. Every endpoint is relative to that base: | Method | Path | What it does | | - | - | - | | `GET` | `/ServiceProviderConfig` | Capability document (RFC 7643 §5) | | `GET` | `/Schemas`, `/Schemas/{id}` | Attribute definitions | | `GET` | `/ResourceTypes` | The resources this service exposes | | `GET` `POST` | `/Users` | List / filter, and provision a member | | `GET` `PUT` `PATCH` `DELETE` | `/Users/{id}` | Read, replace, patch, deprovision | | `GET` `POST` | `/OAuthClients` | List / filter, and provision an OAuth client | | `GET` `PUT` `PATCH` `DELETE` | `/OAuthClients/{id}` | Read, replace, patch, revoke | ## Token issuance Your connector authenticates with a **SCIM credential** — a bearer token that begins with `scim_`. It is not a user's session and not an API key; it belongs to the workspace, carries its own tenant binding, and reaches `/scim/v2` and nothing else. To issue one, an **owner or admin** goes to the KnoxCall dashboard and mints a credential from the Single sign-on settings (`POST /admin/scim/credentials` if you are scripting it). Minting requires a recent second-factor step-up made with `"action_scope": "scim.credential"` (an emailed code does not count), is written to the audit log, and asks for an explicit expiry — a credential with no expiry is something you have to choose, never something you get by omitting a field. ```http theme={"dark"} GET /scim/v2/Users?filter=userName%20eq%20%22ada@example.com%22 HTTP/1.1 Host: acme.knoxcall.com Authorization: Bearer scim_3f9c… Accept: application/scim+json ``` The token is shown **exactly once**, when it is minted. It is stored only as a SHA-256 digest, so it cannot be recovered — if it is lost, revoke it and mint another. Use a **Live** credential for `/Users`. Workspace membership is not partitioned into Live and Test data spaces — a workspace has one set of people — so a Test credential is refused on `/Users` with a `403`. A Test credential does provision `/OAuthClients`, which *is* partitioned. Revoking a credential takes effect on the connector's next request: the check is a single indexed lookup evaluated against the database clock, not a cache. ## Content type RFC 7644 §3.1 requires `application/scim+json`, and KnoxCall accepts it on requests and returns it on responses. `application/json` is accepted on requests as well, so a connector that sends the plain type is not broken by it. ## Supported resources ### `/Users` A `User` is a **workspace membership plus a directory record**. The attributes your IdP asserts live on the directory record, never on the person's global KnoxCall account — one workspace's directory must not rename or re-address someone in another workspace. | Attribute | Notes | | - | - | | `userName` | Required, unique within the workspace, **case-insensitive** | | `name.givenName`, `name.familyName`, `name.formatted` | Separate fields, because Entra patches them one operation at a time | | `displayName` | As this workspace's directory asserts it | | `emails[type eq "work"]` | Held on the directory record; it never moves the KnoxCall login address | | `active` | **Derived**, not stored — true exactly while the person holds a live membership on an active account | | `externalId` | Your directory's own identifier; reconciliation matches on it | `active` being derived is the property that matters most in practice: if an administrator removes someone from the workspace on the team page, your next `GET` reports `active: false` without KnoxCall having to write anything. **Provisioning a new person requires a verified domain.** SCIM grants membership without asking the subject to accept an invitation, so KnoxCall applies the same admission rule as SAML just-in-time provisioning: the address must sit within a domain this workspace has proven by DNS. Verify the domain under Settings → Single sign-on first. Someone who is **already** a member can be given a directory record without this. New memberships are always granted at the `member` role. A SCIM connector cannot mint an administrator. #### Deprovisioning Both forms end the membership immediately, and **neither erases personal data** — erasure is a separate, subject-initiated process: * `PATCH` with `active: false` — the membership is revoked and the directory record **stays**, so a later `GET` returns it with `active: false`. This is the shape Entra sends on a soft-delete. * `DELETE` — the same revocation, plus a tombstone on the directory record, so a later `GET` returns `404`. ### `/OAuthClients` A custom resource, `urn:knoxcall:scim:2.0:OAuthClient`, for provisioning machine credentials alongside people. `clientName`, `allowedScopes`, `redirectUris`, `requirePkce`, `requireDpop`, `stepUpScopes` and `active` are writable; `clientId`, `clientType` and `grantTypes` are not. The `client_secret` of a confidential client is returned exactly once, on create. Redirect URIs are validated on registration with the same rule the dashboard and the Management API apply — a non-registrable URI is refused with a `400` rather than silently dropped from the list. ## Filtering `filter` implements the RFC 7644 §3.4.2.2 grammar, not a subset of it: * Comparison operators `eq`, `ne`, `co`, `sw`, `ew`, `gt`, `ge`, `lt`, `le`, and the presence operator `pr` * `and`, `or`, `not`, and parentheses * Value filters on multi-valued attributes — `emails[type eq "work"]` * URN-qualified attribute names Attribute names are matched case-insensitively, as RFC 7643 §2.1 requires, except for the identifiers `id`, `externalId` and `clientId`, which RFC 7643 §7 defines as case-exact. `userName` deliberately folds case, so `userName eq "Ada@Example.com"` finds the record stored as `ada@example.com`. A filter KnoxCall cannot honour is answered `400` with `scimType: "invalidFilter"` — it is never ignored, and you will never receive an unfiltered list in response to a filter we did not understand. Pagination is `startIndex` (1-based) and `count`, capped at the `filter.maxResults` value `/ServiceProviderConfig` advertises. ## Attributes accepted but not stored Entra's default user mapping sends attributes KnoxCall has no home for. Rejecting them would refuse the **whole** `PatchOp` — including the `active: false` in the same request — so an offboarding would silently not happen. KnoxCall therefore accepts these, names them in the audit record, and stores none of them: | Schema | Attributes | | - | - | | `urn:ietf:params:scim:schemas:core:2.0:User` | `title`, `preferredLanguage`, `addresses[…]`, `phoneNumbers[…]` | | `urn:ietf:params:scim:schemas:extension:enterprise:2.0:User` | `employeeNumber`, `costCenter`, `organization`, `division`, `department`, `manager` | Anything outside that set — an unknown core attribute, a custom extension, a foreign schema URN — is still refused with `scimType: "invalidPath"`, and the schema qualifier on a path is read, so `urn:…:enterprise:2.0:User:active` does **not** deactivate anyone. ## Plan requirement SCIM provisioning is an **Enterprise** feature. **Discovery is never gated.** `/ServiceProviderConfig`, `/Schemas` and `/ResourceTypes` answer on every plan, so a connector can always be configured and can always read this page's URL out of the capability document. **The resource endpoints are.** A workspace whose plan does not include SCIM receives `403` on `/Users` and `/OAuthClients` with a SCIM Error object: ```json theme={"dark"} { "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], "status": "403", "detail": "SCIM provisioning requires the Enterprise plan. …", "error": "scim_plan_required" } ``` The `error` member is a KnoxCall extension, because RFC 7644 §3.12's `scimType` vocabulary is closed and has no value meaning "your plan does not include this". A SCIM client ignores the extra member; `schemas`, `status` and `detail` are exactly what the RFC specifies. There is no `scimType` on this refusal — giving it a near-miss value would make a connector retry with a different body forever. ### What happens if the subscription lapses **Nothing stops.** A workspace whose Enterprise subscription goes `past_due`, enters dunning or is cancelled **keeps being provisioned**. An identity provider does not read a `403` as "the invoice is unpaid" — Entra marks the object in error, retries, and eventually quarantines the whole provisioning job, and restarting a quarantined job re-runs a full reconciliation. Turning a card decline into that would churn every member of the workspace at the moment the people who could fix the card are the ones losing access. What a lapse does stop is **issuing a new SCIM credential**. Minting requires a current Enterprise plan; connectors that already hold one keep working. A workspace that deliberately **downgrades** to a lower tier is a different case — that is a choice, not an accident of billing — and its resource endpoints are refused as above. Listing and revoking credentials is never gated. A workspace that has stopped paying must still be able to see which connectors hold a credential and switch them off. ## Rate limits An identity provider does not send traffic like a person. Entra reconciles in bursts, retries aggressively, and treats a `429` without a `Retry-After` header as a hard failure rather than as backpressure. KnoxCall's limits are built around that. | | | | - | - | | **Unit** | Per workspace *and per credential type*, not per source address — your connector's budget is its own, and is never spent by administrators using the SCIM endpoints from a browser | | **Budget** | 600 requests per 60 seconds, plus a 150-request burst — 750 in any 60-second window | | **On exhaustion** | `429`, always with `Retry-After` in seconds | | **If metering is unavailable** | `503`, also with `Retry-After` — a KnoxCall-side fault, never reported as `429` | The unit is the **workspace** deliberately. Entra's provisioning service egresses from a shared Azure address pool, so two KnoxCall customers' reconciliations routinely arrive from the same IP; a purely per-address budget would let one customer's initial sync throttle another's connector. A separate per-address flood shield still sits in front of the mount, set above the per-workspace budget so that one workspace can never consume it on its own. A `429` body is a SCIM Error object like any other refusal: ```http theme={"dark"} HTTP/1.1 429 Too Many Requests Retry-After: 37 Content-Type: application/scim+json { "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], "status": "429", "detail": "Too many SCIM requests for this workspace. …", "error": "scim_rate_limited" } ``` **Honour `Retry-After`.** A provisioning cycle interrupted by a `429` can be resumed without losing state — nothing was half-applied, because the request was shed before it reached a handler. At 750 requests per minute a full first-time synchronisation of 10,000 users completes in about fifteen minutes, and an incremental cycle never approaches the ceiling. ## Error codes Alongside the RFC 7644 §3.12 members, KnoxCall refusals that the `scimType` vocabulary cannot express carry an `error` member: | `error` | Status | Meaning | | - | - | - | | `scim_plan_required` | `403` | The workspace's plan does not include SCIM provisioning | | `scim_rate_limited` | `429` | The workspace's SCIM budget is spent; retry after `Retry-After` | | `scim_rate_limiter_unavailable` | `503` | KnoxCall could not meter the request and did not serve it | | `scim_users_live_only` | `403` | A Test credential was presented to `/Users` | | `operator_custody_blocked` | `403` | A KnoxCall operator is not permitted to act on this workspace's credentials | A refusal never states whether some other workspace exists. Your credential binds the request to one workspace, so there is nothing to disambiguate. ## Setting up Microsoft Entra ID 1. Mint a SCIM credential in KnoxCall (Settings → Single sign-on) and copy the `scim_…` token. 2. In Entra, open your enterprise application → **Provisioning** → **Automatic**. 3. **Tenant URL**: `https://.knoxcall.com/scim/v2` 4. **Secret Token**: the `scim_…` token. 5. **Test Connection** — Entra fetches `/ServiceProviderConfig` and `/Schemas`. 6. Leave the default user attribute mapping as it is. The attributes KnoxCall does not store are accepted rather than refused, precisely so that the default mapping works unmodified. 7. Verify your email domain in KnoxCall before starting the first cycle, or every create for a new person is refused. Okta is configured the same way: **Base URL** `https://.knoxcall.com/scim/v2`, OAuth Bearer Token, and *Import New Users and Profile Updates* + *Push New Users* + *Push Profile Updates* + *Deactivate Users*. ## What KnoxCall does not implement `/ServiceProviderConfig` is the authoritative answer, and it says so honestly: * **Bulk** operations — not supported * **Sorting** — not supported * **ETags** — not supported * **`changePassword`** — not supported, and never will be: KnoxCall does not hold member passwords * **`/Groups`** — not yet exposed # Create Secret Source: https://docs.knoxcall.com/api-reference/secrets/create Creates a new encrypted string secret ## Create Secret ```http theme={"dark"} POST /v1/secrets ``` Creates a new encrypted secret. Pass `secret_type` to choose the variant — the default is `string`. For type-specific convenience endpoints see [Create OAuth2 Secret](./create-oauth2) and [Create Certificate Secret](./create-certificate). Secret names are normalized: spaces are replaced with underscores. For example, `"Stripe API Key"` becomes `STRIPE_API_KEY`. The normalized name is returned as both `name` and `shortcode_name`. ### String Secret | Field | Type | Required | Description | | - | - | - | - | | `name` | string | Yes | Display name for the secret (spaces → underscores) | | `value` | string | Yes | The secret value to encrypt and store | | `collection_id` | uuid | No | Assign to a collection | | `expires_at` | string | No | ISO-8601 datetime after which the secret is considered expired | | `strict_expiry_enforcement` | boolean | No | If `true`, injection is blocked when the secret is expired. Defaults to `false` (warn-only) | ### Response ```json theme={"dark"} { "data": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "STRIPE_API_KEY", "shortcode_name": "STRIPE_API_KEY", "base_environment": "production", "environment_count": 1, "secret_type": "string", "collection_id": null, "expires_at": null, "strict_expiry_enforcement": false }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ```typescript Node.js theme={"dark"} import { KnoxCall } from "@knoxcall/sdk"; const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET const secret = await client.secrets.create({ name: "STRIPE_API_KEY", value: "sk_live_..." }); ``` ```python Python theme={"dark"} from knoxcall import KnoxCall client = KnoxCall() # credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET secret = client.secrets.create( name="STRIPE_API_KEY", secret_type="string", value="sk_live_..." ) ``` ```bash cURL theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) curl -X POST https://api.knoxcall.com/v1/secrets \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "STRIPE_API_KEY", "value": "sk_live_..." }' ``` ### Errors | Status | Type | Description | | - | - | - | | 400 | `validation_error` | Missing required fields or invalid collection | | 403 | `plan_limit` | Secret limit reached for your subscription plan | | 409 | `conflict` | A secret with this name already exists | # Create Certificate Secret Source: https://docs.knoxcall.com/api-reference/secrets/create-certificate Creates a certificate-type secret for use with mTLS-authenticated upstream APIs ## Create Certificate Secret ```http theme={"dark"} POST /v1/secrets/certificate ``` Creates a certificate-type secret for use with mTLS-authenticated upstream APIs. KnoxCall accepts PEM, PKCS#7, CRT/CER, PFX/P12, and KEY formats. For PEM-format certificates, KnoxCall automatically extracts the subject, issuer, and expiry date and returns them in the response metadata. ### Request Body | Field | Type | Required | Description | | - | - | - | - | | `name` | string | Yes | Secret name (spaces → underscores) | | `certificate_content` | string | Yes | Certificate in PEM, PKCS#7, CRT/CER format, or base64-encoded DER/PFX/P12 | | `private_key` | string | No | PEM-encoded private key to store alongside the certificate | | `passphrase` | string | No | Passphrase to decrypt the private key. Encrypted and stored separately | | `certificate_type` | string | No | `pem` (default), `pfx`, `p12`, `crt`, `cer`, `key`, `pkcs7`, `p7b`, or `p7c` | | `collection_id` | uuid | No | Assign to a collection | ### Response ```json theme={"dark"} { "data": { "id": "b9e3f0a5-4c6d-5e8f-0f1a-3b5d7e9f1a3b", "name": "MY_TLS_CERT", "secret_type": "certificate", "certificate_type": "pem", "base_environment": "production", "collection_id": null, "metadata": { "subject": "CN=example.com, O=Example Inc, C=US", "issuer": "CN=Let's Encrypt Authority X3, O=Let's Encrypt, C=US", "expires_at": "2026-03-15T12:00:00.000Z", "has_private_key": true } }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ```typescript Node.js theme={"dark"} import fs from "fs"; import { KnoxCall } from "@knoxcall/sdk"; const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET const secret = await client.secrets.createCertificate({ name: "MY_TLS_CERT", certificate_type: "pem", certificate_content: fs.readFileSync("client.pem", "utf8"), private_key: fs.readFileSync("client.key", "utf8") }); ``` ```bash cURL — PEM with private key theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) curl -X POST https://api.knoxcall.com/v1/secrets/certificate \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "MY_TLS_CERT", "certificate_type": "pem", "certificate_content": "-----BEGIN CERTIFICATE-----\nMIID...==\n-----END CERTIFICATE-----", "private_key": "-----BEGIN PRIVATE KEY-----\nMIIE...==\n-----END PRIVATE KEY-----" }' ``` ```bash cURL — PEM CA certificate only theme={"dark"} # $TOKEN is a minted OAuth access token — see /api-reference/authentication curl -X POST https://api.knoxcall.com/v1/secrets/certificate \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "CA_CERT", "certificate_type": "pem", "certificate_content": "-----BEGIN CERTIFICATE-----\nMIID...==\n-----END CERTIFICATE-----" }' ``` You can also create certificate secrets via `POST /v1/secrets` with `secret_type: "certificate"` and the same body fields. ### Errors | Status | Type | Description | | - | - | - | | 400 | `validation_error` | Missing required fields or invalid `certificate_type` | | 403 | `plan_limit` | Secret limit reached for your subscription plan | | 409 | `conflict` | A secret with this name already exists | # Create OAuth2 Secret Source: https://docs.knoxcall.com/api-reference/secrets/create-oauth2 Creates an OAuth2-type secret with automatic token refresh managed by KnoxCall ## Create OAuth2 Secret ```http theme={"dark"} POST /v1/secrets/oauth2 ``` Creates an OAuth2-type secret. KnoxCall stores the OAuth2 configuration encrypted at rest and manages token refresh automatically. After creation, complete the authorization flow by redirecting your user to the `redirect_uri` returned in the response. **Supported providers:** `google`, `github`, `salesforce`, `microsoft`, `slack`, `custom` For `custom` providers, you must supply `auth_url` and `token_url`. OAuth2 secrets require a **Pro or Enterprise** plan. ### Request Body | Field | Type | Required | Description | | - | - | - | - | | `name` | string | Yes | Secret name (spaces → underscores) | | `provider` | string | Yes | OAuth2 provider: `google`, `github`, `salesforce`, `microsoft`, `slack`, or `custom` | | `client_id` | string | Yes | OAuth2 client ID from your provider's developer console | | `client_secret` | string | Conditional | OAuth2 client secret. Required unless using `mtls_certificate_id` or `grant_type: implicit` | | `grant_type` | string | No | `authorization_code` (default), `password`, or `implicit` | | `scopes` | string\[] | No | OAuth2 scopes to request. Defaults to the provider's recommended scopes | | `auth_url` | string | No | Authorization endpoint URL. Required for `custom` provider | | `token_url` | string | No | Token endpoint URL. Required for `custom` provider | | `username` | string | Conditional | Username for `password` grant type | | `password` | string | Conditional | Password for `password` grant type | | `mtls_certificate_id` | uuid | No | ID of a `certificate`-type secret for mTLS auth to the token endpoint | | `collection_id` | uuid | No | Assign to a collection | ### Response ```json theme={"dark"} { "data": { "id": "a7c1d8f3-2e4b-4c6a-8d9e-1f3b5a7c9d2e", "name": "GOOGLE_OAUTH", "secret_type": "oauth2", "provider": "google", "redirect_uri": "https://api.knoxcall.com/auth/oauth2/callback/a7c1d8f3-...", "connection_status": "not_connected", "base_environment": "production", "collection_id": null, "mtls_certificate_id": null }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` Register the returned `redirect_uri` as an authorized redirect URL in your OAuth2 provider's app settings before redirecting users through the authorization flow. ```typescript Node.js theme={"dark"} import { KnoxCall } from "@knoxcall/sdk"; const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET const secret = await client.secrets.createOAuth2({ name: "GOOGLE_OAUTH", provider: "google", client_id: "1234567890.apps.googleusercontent.com", client_secret: "GOCSPX-abc123", scopes: ["openid", "email", "profile"] }); // Redirect user to secret.redirect_uri to complete authorization ``` ```bash cURL — Authorization Code (Google) theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) curl -X POST https://api.knoxcall.com/v1/secrets/oauth2 \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "GOOGLE_OAUTH", "provider": "google", "client_id": "1234567890.apps.googleusercontent.com", "client_secret": "GOCSPX-abc123", "scopes": ["openid", "email", "profile"] }' ``` ```bash cURL — Custom Provider theme={"dark"} # $TOKEN is a minted OAuth access token — see /api-reference/authentication curl -X POST https://api.knoxcall.com/v1/secrets/oauth2 \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "MY_OAUTH", "provider": "custom", "client_id": "my-client-id", "client_secret": "my-client-secret", "auth_url": "https://auth.example.com/authorize", "token_url": "https://auth.example.com/token", "scopes": ["read", "write"] }' ``` You can also create OAuth2 secrets via `POST /v1/secrets` with `secret_type: "oauth2"` and the same body fields. ### Errors | Status | Type | Description | | - | - | - | | 400 | `validation_error` | Missing required fields, invalid provider, or mTLS certificate not found | | 403 | `plan_limit` | OAuth2 secrets require Pro or Enterprise plan, or secret limit reached | | 409 | `conflict` | A secret with this name already exists | # Delete Secret Source: https://docs.knoxcall.com/api-reference/secrets/delete Permanently deletes a secret and all of its environment-specific values ## Delete Secret ```http theme={"dark"} DELETE /v1/secrets/:id ``` Permanently deletes a secret and all of its environment-specific values. ### Path Parameters | Parameter | Type | Description | | - | - | - | | `id` | uuid | The secret ID | ### Response ```json theme={"dark"} { "data": { "deleted": true }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ### Errors | Status | Type | Description | | - | - | - | | 404 | `not_found` | Secret not found | | 409 | `in_use` | Secret is currently assigned to one or more routes | You cannot delete a secret that is in use by a route. Remove the secret from all routes first, then delete it. # Get Secret Source: https://docs.knoxcall.com/api-reference/secrets/get Returns metadata for a single secret — the encrypted value is not included ## Get Secret ```http theme={"dark"} GET /v1/secrets/:id ``` Returns metadata for a single secret. The encrypted value is **not** included. ### Path Parameters | Parameter | Type | Description | | - | - | - | | `id` | uuid | The secret ID | ### Response ```json theme={"dark"} { "data": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "STRIPE_API_KEY", "shortcode_name": "STRIPE_API_KEY", "base_environment": "production", "secret_type": "string", "collection_id": null, "created_at": "2026-01-15T09:30:00.000Z", "environment_count": 3, "expires_at": null, "strict_expiry_enforcement": false, "environments": [ { "environment_name": "production", "value_version": 4, "updated_at": "2026-03-15T10:30:00.000Z", "expires_at_override": null }, { "environment_name": "staging", "value_version": 1, "updated_at": "2026-01-15T09:30:00.000Z", "expires_at_override": "2026-12-31T00:00:00.000Z" } ] }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ### The `environments` array One entry per environment that holds a value, ordered by environment name. | Field | Type | Description | | - | - | - | | `environment_name` | string | The environment this entry describes | | `value_version` | integer | Counter of genuine value writes, starting at 1 | | `updated_at` | date-time | Last change of **any** kind to this environment's row | | `expires_at_override` | date-time \| null | Per-environment expiry; null inherits the secret-level `expires_at` | ### Detecting a rotation you did not perform `value_version` is the only field here that means "the stored value changed". It increments **only** for writes that replace the value: * `PUT /v1/secrets/:id/value` * value and certificate updates made in the KnoxCall dashboard * platform-managed custodial key rotation * importing over an existing environment during a secret-store migration It deliberately does **not** increment for writes that leave the value intact: * OAuth2 token refreshes (the token churns; the client secret does not) * per-environment expiry overrides * certificate metadata re-parsing and mTLS binding changes * renaming an environment * BYOK rewrap — the same plaintext re-encrypted under a new tenant key All of those move `updated_at`, which is why `updated_at` cannot be used as a rotation signal, and a BYOK rewrap changes the stored checksum, which is why that cannot either. Store the `value_version` returned by your own `PUT /v1/secrets/:id/value` call and compare it with the one here. A higher number means someone rotated the secret outside your tooling. Creating an environment's first value produces version 1, not 2 — creation is not a rotation. ### Errors | Status | Type | Description | | - | - | - | | 404 | `not_found` | Secret not found | # List Secrets Source: https://docs.knoxcall.com/api-reference/secrets/list Returns a paginated list of secrets with metadata only (no values) ## List Secrets ```http theme={"dark"} GET /v1/secrets ``` Returns a paginated list of secrets with metadata only (no values). ### Query Parameters | Parameter | Type | Default | Description | | - | - | - | - | | `page` | integer | `1` | Page number | | `per_page` | integer | `20` | Items per page (max 100) | | `sort` | string | `name` | Sort by: `created_at`, `name`, or `base_environment` | | `order` | string | `asc` | Sort direction: `asc` or `desc` | ### Response ```json theme={"dark"} { "data": [ { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "STRIPE_API_KEY", "shortcode_name": "STRIPE_API_KEY", "base_environment": "production", "secret_type": "string", "collection_id": null, "created_at": "2026-01-15T09:30:00.000Z", "environment_count": 3, "expires_at": null, "strict_expiry_enforcement": false } ], "meta": { "total": 12, "page": 1, "per_page": 20, "total_pages": 1, "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ```typescript Node.js theme={"dark"} import { KnoxCall } from "@knoxcall/sdk"; const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET const page = await client.secrets.list({ page: 1, per_page: 20 }); const secrets = page.data; ``` ```python Python theme={"dark"} from knoxcall import KnoxCall client = KnoxCall() # credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET page = client.secrets.list(page=1, per_page=20) secrets = page["data"] ``` ```bash cURL theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) curl "https://api.knoxcall.com/v1/secrets?sort=name&order=asc" \ -H "Authorization: Bearer $TOKEN" ``` # Get OAuth2 Access Token Source: https://docs.knoxcall.com/api-reference/secrets/oauth2-token Returns the current access token for an OAuth2 secret, refreshing it automatically if expired ## Get OAuth2 Access Token ```http theme={"dark"} GET /v1/secrets/:id/oauth2/token ``` For OAuth2-type secrets, returns the current access token. If the token has expired, KnoxCall will automatically refresh it before returning. ### Required scope `secrets:oauth_token` (or `secrets:*`). This endpoint returns a **live upstream credential** and refreshes it at the provider, so it is deliberately not covered by `secrets:read`, whose scope is secret *metadata*. If you authenticate with an OAuth access token that was deliberately narrowed, its grant must include `secrets:oauth_token`; a token narrowed to `secrets:read` receives `403 insufficient_scope` and the `WWW-Authenticate` header names the scope to request. API keys (`tk_`/`AKE`) and key-mirrored OAuth clients carry no scope grant and are unaffected. Changed 2026-08-26 — see the [changelog](/api-reference/changelog). ### Path Parameters | Parameter | Type | Description | | - | - | - | | `id` | uuid | The secret ID (must be an OAuth2-type secret) | ### Query Parameters | Parameter | Type | Default | Description | | - | - | - | - | | `environment` | string | Base environment | Target a specific environment | ### Response ```json theme={"dark"} { "data": { "access_token": "eyJhbGciOiJSUzI1NiIs...", "expires_at": "2026-03-10T15:30:00.000Z", "token_type": "Bearer", "connection_status": "connected" }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ### Errors | Status | Type | Description | | - | - | - | | 400 | `invalid_type` | Secret is not an OAuth2 type | | 403 | `insufficient_scope` | The access token's scope grant does not include `secrets:oauth_token` | | 404 | `not_found` | Secret not found | | 500 | `token_error` | Failed to retrieve or refresh the OAuth2 token | # Update Secret Metadata Source: https://docs.knoxcall.com/api-reference/secrets/update Updates the expiry metadata on an existing secret without changing the encrypted value ## Update Secret Metadata ```http theme={"dark"} PATCH /v1/secrets/:id ``` Updates the expiry metadata on an existing secret. Use this to set or clear `expires_at` and `strict_expiry_enforcement` without changing the encrypted value. ### Path Parameters | Parameter | Type | Description | | - | - | - | | `id` | uuid | The secret ID | ### Request Body At least one field is required. | Field | Type | Description | | - | - | - | | `expires_at` | string \| null | ISO-8601 datetime for expiry, or `null` to clear | | `strict_expiry_enforcement` | boolean | `true` to block injection when expired; `false` for warn-only | ### Response ```json theme={"dark"} { "data": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "STRIPE_API_KEY", "expires_at": "2027-01-15T00:00:00.000Z", "strict_expiry_enforcement": true }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ### Errors | Status | Type | Description | | - | - | - | | 400 | `validation_error` | No fields provided, invalid `expires_at`, or non-boolean `strict_expiry_enforcement` | | 404 | `not_found` | Secret not found | # Update Secret Value Source: https://docs.knoxcall.com/api-reference/secrets/update-value Replaces the encrypted value of an existing string secret ## Update Secret Value ```http theme={"dark"} PUT /v1/secrets/:id/value ``` Replaces the encrypted value of an existing string secret. You can target a specific environment to set an environment-specific override. ### Path Parameters | Parameter | Type | Description | | - | - | - | | `id` | uuid | The secret ID | ### Query Parameters | Parameter | Type | Default | Description | | - | - | - | - | | `environment` | string | Base environment | Target a specific environment | ### Request Body | Field | Type | Required | Description | | - | - | - | - | | `value` | string | Yes | The new secret value | ### Response ```json theme={"dark"} { "data": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "STRIPE_API_KEY", "environment": "production", "value_version": 5 }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` `value_version` is the environment's version **after** this write — `1` when this call stored the environment's first value, otherwise the previous version plus one. Because it is the version this call produced, storing it has no read-after-write race with a rotation happening concurrently. Compare it later with `environments[].value_version` from [`GET /v1/secrets/:id`](/api-reference/secrets/get) to detect a rotation performed outside your tooling. The value write and the version increment commit in one transaction, so a rotation is never recorded without its counter and a counter is never incremented without a rotation. ### Errors | Status | Type | Description | | - | - | - | | 400 | `validation_error` | Missing `value` field | | 404 | `not_found` | Secret not found | # Error Reference Source: https://docs.knoxcall.com/api-reference/tenant-kms/errors Shared error codes for all Tenant KMS endpoints. ## Error reference Tenant KMS endpoints return errors in one of two shapes. The **onboard** and **probe** failures return a human-readable message in `error` (with an optional `details` field): ```json theme={"dark"} { "error": "KMS access denied. Check that the IAM/RBAC grant gives KnoxCall both Encrypt and Decrypt on this key.", "details": "..." } ``` The **unseal** and **rotate** endpoints return a machine-readable code in `error` plus a `message`: ```json theme={"dark"} { "error": "not_sealed", "message": "Tenant is not currently sealed" } ``` This table is non-exhaustive — it lists the common failures. Validation errors (missing `provider`, `kms_key_ref`, malformed `config`) also return `400` with a descriptive `error` message. ### Onboard / probe failures (`POST /admin/tenant-kms`) These return `{ error: "", details?: "" }`. | Status | `error` message | Meaning | | - | - | - | | `400` | `KMS access denied. Check that the IAM/RBAC grant gives KnoxCall both Encrypt and Decrypt on this key.` | The probe encrypt/decrypt round-trip was denied by your KMS IAM/RBAC policy | | `400` | `Field "" is not permitted in tenant KMS config. ...` | `config` contained a blocked static-credential field (access key, secret key, client secret, private key, service-account key, etc.) — use STS/WIF/SA-impersonation instead | | `400` | `KMS probe roundtrip mismatch — refusing to onboard a key we cannot reliably decrypt with.` | The wrap+unwrap probe did not return the original plaintext | | `400` | `KMS probe failed.` | The probe failed for another reason (see `details`) | | `401` | `authentication required` | No authenticated session | | `400` | `X-Tenant-ID header required` | `X-Tenant-ID` header missing | | `403` | `` | Caller is not an owner/admin of the tenant | | `500` | `Failed to persist BYOK configuration.` | The configuration probe passed but persisting it failed (see `details`) | | `502` | `KMS provider unreachable during probe. Try again in a minute.` | KnoxCall could not reach the KMS provider during the probe | ### Step-up required (`POST /admin/tenant-kms/unseal`, `POST /admin/tenant-kms/rotate`, `POST /admin/tenant-kms/revert`) These endpoints require a recent step-up verification. When one is missing or expired: | Status | Response | Meaning | | - | - | - | | `403` | `{ "error": "Recent step-up verification required for this action", "requires_step_up": true, "max_age_minutes": , "action_scope": "tenant.kms", "allowed_methods": ["passkey", "2fa"] }` | Re-verify with a second factor within the last `max_age_minutes`, passing the response's `action_scope` as `action_scope`, and retry: `POST /auth/2fa/verify-step-up` (authenticator-app code), `POST /auth/passkey/verify-step-up` (passkey), or `POST /auth/2fa/webauthn/verify-step-up` (security key or passkey, challenge from `POST /auth/2fa/webauthn/step-up-options`). An emailed code does not satisfy this gate | ### Unseal / rotate failures These return `{ error: "", message: "" }`. | Status | Code | Meaning | | - | - | - | | `404` | `no_kms_config` | No customer KMS configured for this tenant (unseal only) | | `404` | `no_active_key` | No active master key found — required for rotation | | `409` | `not_sealed` | Tenant is not currently sealed (unseal only) | | `409` | `sealed` | Cannot rotate while sealed — unseal first | | `500` | `provider_resolve_failed` | KMS provider could not be constructed from the stored config (unseal) | | `500` | `provision_failed` | Provisioning a new tenant master key failed (rotate) | | `502` | `unseal_failed` | Unseal roundtrip failed — KMS still unreachable or access still denied (includes a `reason` field) | # Onboard / Configure KMS Source: https://docs.knoxcall.com/api-reference/tenant-kms/onboard Onboard a customer-managed KMS provider (BYOK) or replace an existing configuration via UPSERT. ## POST /admin/tenant-kms Onboard a customer KMS provider. KnoxCall performs a live wrap + unwrap round-trip on a 32-byte test payload before committing — if the IAM/RBAC grant isn't working, the request fails immediately and nothing is stored. Calling this endpoint when a KMS configuration already exists **replaces it** (UPSERT). The probe runs first; if it fails, the existing config is unchanged. **Auth**: Session JWT (`Authorization: Bearer `) **plus** the `X-Tenant-ID` header, and the caller must have the **owner** or **admin** role. Requests missing `X-Tenant-ID` are rejected with `400 X-Tenant-ID header required`. No step-up required. ### Request body **`provider`** — `aws` | `gcp` | `azure` **`kms_key_ref`** — The provider-specific key identifier: * **AWS**: full key ARN (`arn:aws:kms:REGION:ACCOUNT:key/KEY-ID` or `.../alias/ALIAS`) * **GCP**: full resource path (`projects/P/locations/L/keyRings/R/cryptoKeys/K`) * **Azure**: vault key URL (`https://VAULT.vault.azure.net/keys/KEY-NAME`) **`config`** — Provider-specific authentication references. Static long-lived credentials (access keys, service account JSON, client secrets) are rejected at the API level. | Provider | Required | Optional | | - | - | - | | `aws` | `region`, `role_arn` | `external_id` | | `gcp` | `impersonate_service_account` | `project_id` | | `azure` | `vault_url` | `azure_tenant_id`, `client_id`, `client_secret_env_var` | ```bash AWS KMS theme={"dark"} curl -X POST https://admin.knoxcall.com/admin/tenant-kms \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "X-Tenant-ID: $KC_TENANT_ID" \ -H "Content-Type: application/json" \ -d '{ "provider": "aws", "kms_key_ref": "arn:aws:kms:us-east-1:111122223333:key/abcd-1234", "config": { "region": "us-east-1", "role_arn": "arn:aws:iam::111122223333:role/KnoxCallKMSAccess", "external_id": "550e8400-e29b-41d4-a716-446655440000" } }' ``` ```bash GCP Cloud KMS theme={"dark"} curl -X POST https://admin.knoxcall.com/admin/tenant-kms \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "X-Tenant-ID: $KC_TENANT_ID" \ -H "Content-Type: application/json" \ -d '{ "provider": "gcp", "kms_key_ref": "projects/my-project/locations/global/keyRings/knoxcall-keys/cryptoKeys/tenant-master-key", "config": { "impersonate_service_account": "knoxcall-kms-sa@my-project.iam.gserviceaccount.com", "project_id": "my-project" } }' ``` ```bash Azure Key Vault theme={"dark"} curl -X POST https://admin.knoxcall.com/admin/tenant-kms \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "X-Tenant-ID: $KC_TENANT_ID" \ -H "Content-Type: application/json" \ -d '{ "provider": "azure", "kms_key_ref": "https://my-kv.vault.azure.net/keys/tenant-master-key", "config": { "vault_url": "https://my-kv.vault.azure.net", "azure_tenant_id": "aaaabbbb-cccc-dddd-eeee-ffffgggghhhh", "client_id": "11112222-3333-4444-5555-666677778888", "client_secret_env_var": "AZURE_KC_CLIENT_SECRET" } }' ``` ### Response ```json theme={"dark"} { "ok": true, "provider": "aws", "kms_key_ref": "arn:aws:kms:us-east-1:111122223333:key/abcd-1234", "new_key_version": 1, "new_key_id": "d290f1ee-6c54-4b01-90e6-d701748f0851", "rewrap_status": "enqueued", "note": "A background rewrap pass will re-encrypt existing tenant data under the new key. Existing in-flight agent sessions remain valid for up to 5 minutes (BYOK session TTL)." } ``` `rewrap_status` is always `"enqueued"` — KnoxCall unconditionally issues a background rewrap lease after onboarding. # Revert to Operator-Managed Key Source: https://docs.knoxcall.com/api-reference/tenant-kms/revert Off-board BYOK: return the tenant to a KnoxCall-managed master key and remove the customer KMS configuration. ## POST /admin/tenant-kms/revert Revert the tenant from BYOK back to a KnoxCall-managed (operator) master key. The inverse of onboarding: a new operator-wrapped master key is provisioned and activated, existing data is re-encrypted under it, and the customer KMS configuration is removed. Your KMS key itself is never modified — you can re-onboard BYOK at any time. **Auth**: `Authorization: Bearer ` + **`X-Tenant-ID`**. Requires an owner/admin session. This endpoint requires a **recent step-up verification** (re-authentication) performed within the last **2 minutes** by the signed-in user (the same tight window as unseal — reverting removes your KMS revocation control over data-at-rest). No step-up header or token is sent on this request — the server checks for a recent, unused verification tied to your session. To create one, re-authenticate with a second factor, passing `"action_scope": "tenant.kms"` in the body: `POST /auth/2fa/verify-step-up` (authenticator-app code), `POST /auth/passkey/verify-step-up` (passkey, challenge from `POST /auth/passkey/authenticate-options`), or `POST /auth/2fa/webauthn/verify-step-up` (security key or passkey, challenge from `POST /auth/2fa/webauthn/step-up-options`). An emailed code does not satisfy this gate. `GET /auth/step-up/status` reports unscoped verifications only, so it does not show a `tenant.kms` one. If none is present, the request returns `403` with `{ "error": "Recent step-up verification required for this action", "requires_step_up": true, "max_age_minutes": 2, "action_scope": "tenant.kms", "allowed_methods": ["passkey", "2fa"] }`. The behavior depends on whether your KMS is reachable, which the server verifies with a live wrap/unwrap probe: ### KMS reachable — lossless revert The new operator-managed key is activated immediately and a background rewrap job re-encrypts all existing data under it. Your KMS configuration stays in place until the last row wrapped under your key has been migrated (it is needed to decrypt them), then is removed automatically. ```json theme={"dark"} { "reverted": true, "path": "rewrap", "new_key_version": 5, "rewrap_lease_id": 42, "note": "Re-encryption under the operator-managed key is queued. The customer KMS config will be removed automatically once it completes." } ``` ### KMS unreachable — forced revert (data loss) If the probe fails (revoked IAM grant, deleted role trust, disabled key), secrets written while BYOK was active **cannot be re-encrypted and become permanently unreadable**. The server refuses with `409`: ```json theme={"dark"} { "error": "kms_unreachable_data_loss", "probe_reason": "access_denied: KMS access denied by aws: ...", "message": "The customer KMS is unreachable, so secrets wrapped under it cannot be re-encrypted — reverting now makes them permanently unreadable. Either restore KMS access first (then revert re-encrypts everything losslessly), or retry with { \"acknowledge_data_loss\": true } to revert anyway." } ``` Retry with an explicit acknowledgement to proceed: ```json theme={"dark"} { "acknowledge_data_loss": true } ``` The response then has `"path": "forced_data_loss"` and `"rewrap_lease_id": null`. The KMS configuration is removed immediately and a snapshot of it is written to the tamper-evident audit trail — if you later restore KMS access, KnoxCall support can re-attach the configuration to recover secrets wrapped under the old key versions. Prefer restoring KMS access before reverting. A forced revert orphans every secret value written while BYOK was active — you will need to re-enter them. Restoring access first makes the revert completely lossless. The tenant owner receives a security notification email, and a `byok.kms.revert` entry is written to the hash-chained critical audit log. ```bash cURL theme={"dark"} curl -X POST https://admin.knoxcall.com/admin/tenant-kms/revert \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "X-Tenant-ID: $KC_TENANT_ID" \ -H "Content-Type: application/json" \ -d '{}' ``` ```python Python theme={"dark"} resp = requests.post( "https://admin.knoxcall.com/admin/tenant-kms/revert", headers={ "Authorization": f"Bearer {KC_ADMIN_JWT}", "X-Tenant-ID": KC_TENANT_ID, }, json={}, ) resp.raise_for_status() print(resp.json()["path"]) # "rewrap" ``` ```javascript Node.js theme={"dark"} const resp = await fetch("https://admin.knoxcall.com/admin/tenant-kms/revert", { method: "POST", headers: { "Authorization": `Bearer ${KC_ADMIN_JWT}`, "X-Tenant-ID": KC_TENANT_ID, "Content-Type": "application/json", }, body: JSON.stringify({}), }); const { reverted, path, new_key_version } = await resp.json(); ``` # Rotate Master Key Source: https://docs.knoxcall.com/api-reference/tenant-kms/rotate Rotate the tenant master key and enqueue a background rewrap job to re-encrypt existing tenant data. ## POST /admin/tenant-kms/rotate Rotate the tenant master key. Creates a new 32-byte master key wrapped under your customer KMS key, sets it as the active version, and retires the current active version. A background rewrap job immediately begins re-encrypting existing tenant data under the new version. **Auth**: `Authorization: Bearer ` + **`X-Tenant-ID`**. Requires an owner/admin session. This endpoint requires a **recent step-up verification** (re-authentication) performed within the last **5 minutes** by the signed-in user. No step-up header or token is sent on this request — the server checks for a recent, unused verification tied to your session. To create one, re-authenticate with a second factor, passing `"action_scope": "tenant.kms"` in the body: `POST /auth/2fa/verify-step-up` (authenticator-app code), `POST /auth/passkey/verify-step-up` (passkey, challenge from `POST /auth/passkey/authenticate-options`), or `POST /auth/2fa/webauthn/verify-step-up` (security key or passkey, challenge from `POST /auth/2fa/webauthn/step-up-options`). An emailed code does not satisfy this gate. `GET /auth/step-up/status` reports unscoped verifications only, so it does not show a `tenant.kms` one. If none is present, the request returns `403` with `{ "error": "Recent step-up verification required for this action", "requires_step_up": true, "max_age_minutes": 5, "action_scope": "tenant.kms", "allowed_methods": ["passkey", "2fa"] }`. Rotation is **non-reversible**. The old version becomes `retired` and cannot be made active again. Data encrypted under the old version remains decryptable so that existing tenant data can still be read while the background rewrap job completes. Cannot be called while the tenant is sealed — unseal first. ### Response ```json theme={"dark"} { "rotated": true, "new_version": 2, "rewrap_lease_id": 42 } ``` `rewrap_lease_id` identifies the background rewrap job. This job processes 100 rows per batch at 5-second intervals, re-encrypting all existing tenant secrets under `new_version`. The old version remains available for decryption until the rewrap completes. `rewrap_lease_id` is `null` if the rewrap lease failed to issue (the rotation itself succeeded — contact support to trigger rewrap manually). ```bash cURL theme={"dark"} curl -X POST https://admin.knoxcall.com/admin/tenant-kms/rotate \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "X-Tenant-ID: $KC_TENANT_ID" ``` ```python Python theme={"dark"} resp = requests.post( "https://admin.knoxcall.com/admin/tenant-kms/rotate", headers={ "Authorization": f"Bearer {KC_ADMIN_JWT}", "X-Tenant-ID": KC_TENANT_ID, }, ) resp.raise_for_status() print(resp.json()["new_version"]) # 2 ``` ```javascript Node.js theme={"dark"} const resp = await fetch("https://admin.knoxcall.com/admin/tenant-kms/rotate", { method: "POST", headers: { "Authorization": `Bearer ${KC_ADMIN_JWT}`, "X-Tenant-ID": KC_TENANT_ID, }, }); const { rotated, new_version, rewrap_lease_id } = await resp.json(); ``` # Get KMS Status Source: https://docs.knoxcall.com/api-reference/tenant-kms/status Retrieve the current BYOK KMS configuration, all key versions, seal status, and last KMS error. ## GET /admin/tenant-kms Retrieve the current BYOK KMS configuration, all key versions, seal status, and last KMS error. **Auth**: This is a control-plane endpoint on the admin host (e.g. `admin.knoxcall.com` / any `knoxcall.com` host). It is authenticated with a session JWT (`Authorization: Bearer `) plus the `X-Tenant-ID` header and requires an owner or admin role — not an `api.knoxcall.com` API key. No step-up required. ### Response ```json theme={"dark"} { "config": { "provider": "customer_kms_aws", "kms_key_ref": "arn:aws:kms:us-east-1:111122223333:key/abcd-1234", "config": { "region": "us-east-1", "role_arn": "arn:aws:iam::111122223333:role/KnoxCallKMSAccess" }, "sealed_since": null, "last_verified_at": "2026-05-25T10:00:00Z", "last_verify_error": null }, "keys": [ { "id": "d290f1ee-6c54-4b01-90e6-d701748f0851", "version": 2, "wrap_method": "customer_kms", "status": "active", "created_at": "2026-05-25T10:00:00Z", "retired_at": null }, { "id": "a7c1d8f3-2e4b-4c6a-8d9e-1f3b5a7c9d2e", "version": 1, "wrap_method": "customer_kms", "status": "retired", "created_at": "2026-01-01T00:00:00Z", "retired_at": "2026-05-25T10:00:00Z" } ] } ``` `config.provider` in the GET response uses the internal format (`customer_kms_aws`, `customer_kms_gcp`, `customer_kms_azure`) — this is different from the short input form (`aws`, `gcp`, `azure`) used in the onboard request. If no customer KMS is configured for this tenant, the endpoint still returns `200` with `config: null` (and `keys: []`). ```bash cURL theme={"dark"} curl https://admin.knoxcall.com/admin/tenant-kms \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "X-Tenant-ID: $KC_TENANT_ID" ``` ```python Python theme={"dark"} import requests resp = requests.get( "https://admin.knoxcall.com/admin/tenant-kms", headers={ "Authorization": f"Bearer {KC_ADMIN_JWT}", "X-Tenant-ID": KC_TENANT_ID, }, ) resp.raise_for_status() data = resp.json() sealed = data["config"]["sealed_since"] is not None ``` ```javascript Node.js theme={"dark"} const resp = await fetch("https://admin.knoxcall.com/admin/tenant-kms", { headers: { "Authorization": `Bearer ${KC_ADMIN_JWT}`, "X-Tenant-ID": KC_TENANT_ID, }, }); const { config, keys } = await resp.json(); const isSealed = config.sealed_since !== null; ``` # Unseal Source: https://docs.knoxcall.com/api-reference/tenant-kms/unseal Force an immediate unseal attempt after restoring KMS access. ## POST /admin/tenant-kms/unseal Force an immediate unseal attempt after restoring KMS access. This is **not** idempotent — once the tenant is unsealed, calling it again returns `409 not_sealed`. **Auth**: served on the admin host with a session `Authorization: Bearer ` plus the **`X-Tenant-ID`** header (owner/admin role). This action also requires a recent **step-up verification** within a **2-minute** window — enforced server-side against the authenticated user. No step-up header or token is sent on this request; instead the signed-in user must have re-authenticated shortly beforehand with a second factor, passing `"action_scope": "tenant.kms"` in the body: `POST /auth/2fa/verify-step-up` (authenticator-app code), `POST /auth/passkey/verify-step-up` (passkey, challenge from `POST /auth/passkey/authenticate-options`), or `POST /auth/2fa/webauthn/verify-step-up` (security key or passkey, challenge from `POST /auth/2fa/webauthn/step-up-options`). An emailed code does not satisfy this gate. If no recent verification exists, the endpoint returns `403 { "error": "Recent step-up verification required for this action", "requires_step_up": true, "max_age_minutes": 2, "action_scope": "tenant.kms", "allowed_methods": ["passkey", "2fa"] }`. Omitting `X-Tenant-ID` returns `400`. Manual unseal forces immediate recovery rather than waiting for the next BYOK bundle-renewal cycle. ### Response ```json theme={"dark"} { "unsealed": true } ``` ### Error codes | Status | Code | Meaning | | - | - | - | | `404` | `no_kms_config` | No customer KMS configured for this tenant | | `409` | `not_sealed` | Tenant is not currently sealed | | `502` | `unseal_failed` | Unseal attempt failed — KMS still unreachable or access still denied | `unseal_failed` includes a `reason` field: `"access_denied"` (IAM grant still missing) or `"unreachable"` (network issue). ```bash cURL theme={"dark"} curl -X POST https://admin.knoxcall.com/admin/tenant-kms/unseal \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "X-Tenant-ID: $KC_TENANT_ID" ``` ```python Python theme={"dark"} resp = requests.post( "https://admin.knoxcall.com/admin/tenant-kms/unseal", headers={ "Authorization": f"Bearer {KC_ADMIN_JWT}", "X-Tenant-ID": KC_TENANT_ID, }, ) resp.raise_for_status() assert resp.json()["unsealed"] is True ``` ```javascript Node.js theme={"dark"} const resp = await fetch("https://admin.knoxcall.com/admin/tenant-kms/unseal", { method: "POST", headers: { "Authorization": `Bearer ${KC_ADMIN_JWT}`, "X-Tenant-ID": KC_TENANT_ID, }, }); const { unsealed } = await resp.json(); ``` # Versioning Source: https://docs.knoxcall.com/api-reference/versioning How KnoxCall versions its API: the /v1 path major plus the dated KnoxCall-Version header minor, what counts as a breaking change, and how to pin. # Versioning KnoxCall versions the API on two axes: * **Major** — the URL path (`/v1`). It only changes for a wholesale redesign, and old majors keep working. * **Minor** — a **dated revision** selected with the `KnoxCall-Version` request header (`YYYY-MM-DD`). This is the same model Stripe (`Stripe-Version`) and Anthropic (`anthropic-version`) use. ```bash theme={"dark"} curl https://api.knoxcall.com/v1/routes \ -H "Authorization: Bearer $KNOXCALL_ACCESS_TOKEN" \ -H "KnoxCall-Version: 2026-08-05" ``` The current version is **`2026-08-05`**. Every response echoes the version that served it as a `KnoxCall-Version` response header. ## Pinning If you send no `KnoxCall-Version` header, the request runs against the **newest** version. To insulate your integration from future breaking changes, **pin** the header to a dated version; the official SDKs do this automatically, sending the version they were built against. An unknown or malformed `KnoxCall-Version` is rejected: ```json theme={"dark"} { "error": { "type": "invalid_api_version", "message": "Unknown KnoxCall-Version \"2099-12-31\". Supported versions: 2026-08-05.", "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ## What is (and isn't) a breaking change We add fields, enum values, and endpoints **without** a new version — these are **not** breaking, and your client must tolerate them: * **Tolerate unknown fields.** Don't fail if a response grows a new property. * **Tolerate new enum values** on fields you don't strictly switch over. * **Don't depend on property order** or on the absence of a field. A change that could break a conforming client — removing or renaming a field, changing a type, changing a status code, tightening validation — ships under a **new dated version**. Your pinned version keeps the old behaviour; you migrate on your own schedule by bumping the header. ## Changelog Dated API changes are recorded in the [Changelog](/api-reference/changelog). Watch it for new versions and the migration notes that accompany any breaking change. # Create Webhook Source: https://docs.knoxcall.com/api-reference/webhooks/create Create a new webhook to receive real-time event notifications ## Create Webhook ```text theme={"dark"} POST /v1/webhooks ``` Creates a new webhook. The `secret_key` for verifying webhook signatures is returned **only once** in the response. ### Request Body | Field | Type | Required | Description | | - | - | - | - | | `name` | string | Yes | Display name for the webhook | | `url` | string | Yes | The URL to deliver webhook payloads to | | `event_types` | string\[] | Yes | Events to subscribe to (non-empty array). Supported: `request.received`, `request.success`, `request.redirect`, `request.client_error`, `request.server_error`, `request.timeout`, `request.error`, `request.completed`, `audit.event`. See [List Event Types](/api-reference/webhooks/event-types). Subscribing to `audit.event` needs `audit_log:list` on a key, or an Admin or Owner behind a user-bound token (otherwise `403 forbidden`); an OAuth token narrowed by scope also needs `audit-logs:read` (otherwise `403 insufficient_scope`). The same applies to any later update of a webhook that stays subscribed, including a rename. | | `description` | string | No | Description | | `method` | string | No | HTTP method for delivery (defaults to `POST`) | | `auth_type` | string | No | Authentication type: `none`, `basic`, `bearer`, `hmac`, `header` (defaults to `none`) | | `auth_config` | object | No | Auth credentials (depends on `auth_type`) | | `request_headers` | object | No | Custom headers to include in deliveries | | `route_filter` | string\[] | No | Only trigger for events on these route IDs (array of UUIDs) | | `include_request_body` | boolean | No | Include the original request body in the payload (defaults to `true`) | | `include_response_body` | boolean | No | Include the upstream response body (defaults to `false`) | | `include_headers` | boolean | No | Include request/response headers (defaults to `false`) | | `timeout_seconds` | integer | No | Delivery timeout in seconds (defaults to `30`) | | `retry_on_failure` | boolean | No | Retry failed deliveries (defaults to `true`) | | `max_retries` | integer | No | Maximum retry attempts (defaults to `3`) | | `enabled` | boolean | No | Enable immediately (defaults to `true`) | | `hmac_key_id` | uuid | No | Crypto Keys transit key ID to use for HMAC signing (stage 2.5; replaces the `secret_key`-based default signing) | | `hmac_format` | string | No | Signature format when using `hmac_key_id`: `legacy`, `stripe`, `github`, `slack`, `aws-sns`, `custom` | | `hmac_header_name` | string | No | Custom header name when `hmac_format` is `custom` | ### Response ```json theme={"dark"} { "data": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "Error Notifications", "description": "Notify Slack on 5xx errors", "url": "https://hooks.slack.com/services/T00/B00/xxx", "method": "POST", "event_types": ["request.error", "request.timeout"], "auth_type": "none", "enabled": true, "hmac_key_id": null, "hmac_format": null, "hmac_header_name": null, "created_at": "2026-01-20T10:00:00.000Z", "secret_key": "3f9c1a4e8b2d6075e1c93af0d4b78652a9f1e0c3b6d8275a4e1f93c0b7a6d2e7" }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` The `secret_key` is returned **only at creation time**. Store it securely -- you'll need it to verify webhook signatures. See the [Verifying Signatures](/webhooks/verifying-signatures) guide. ```typescript Node.js theme={"dark"} import { KnoxCall } from "@knoxcall/sdk"; const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET const webhook = await client.webhooks.create({ name: "Error Notifications", url: "https://hooks.slack.com/services/T00/B00/xxx", event_types: ["request.error", "request.timeout"], description: "Notify Slack on 5xx errors" }); console.log(`Store webhook secret: ${webhook.secret_key}`); ``` ```python Python theme={"dark"} from knoxcall import KnoxCall client = KnoxCall() # credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET webhook = client.webhooks.create( name="Error Notifications", url="https://hooks.slack.com/services/T00/B00/xxx", event_types=["request.error", "request.timeout"], description="Notify Slack on 5xx errors" ) print(f"Store webhook secret: {webhook['secret_key']}") ``` ```bash cURL theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) curl -X POST https://api.knoxcall.com/v1/webhooks \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Error Notifications", "url": "https://hooks.slack.com/services/T00/B00/xxx", "event_types": ["request.error", "request.timeout"], "description": "Notify Slack on 5xx errors" }' ``` ### Errors | Status | Type | Description | | - | - | - | | 400 | `validation_error` | Missing required fields or empty `event_types` array | # Delete Webhook Source: https://docs.knoxcall.com/api-reference/webhooks/delete Permanently delete a webhook and all of its delivery logs ## Delete Webhook ```text theme={"dark"} DELETE /v1/webhooks/:id ``` Permanently deletes a webhook and all of its delivery logs. ### Path Parameters | Parameter | Type | Description | | - | - | - | | `id` | uuid | The webhook ID | ### Response ```json theme={"dark"} { "data": { "deleted": true }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ### Errors | Status | Type | Description | | - | - | - | | 404 | `not_found` | Webhook not found | # List Event Types Source: https://docs.knoxcall.com/api-reference/webhooks/event-types List the webhook event types that can be subscribed to ## List Event Types ```text theme={"dark"} GET /v1/webhooks/event-types ``` Returns the set of event types you can subscribe to when creating a webhook. Useful for building event pickers in UIs and connectors (Zapier, Make, n8n use this to populate their event dropdowns). ### Response ```json theme={"dark"} { "data": { "event_types": [ { "value": "request.received", "label": "Request > Received", "description": "Triggered when a request is received" }, { "value": "request.success", "label": "Request > Success", "description": "Triggered on a successful 2xx response" }, { "value": "request.redirect", "label": "Request > Redirect", "description": "Triggered on a 3xx redirect response" }, { "value": "request.client_error", "label": "Request > Client_error", "description": "Triggered on a 4xx client error" }, { "value": "request.server_error", "label": "Request > Server_error", "description": "Triggered on a 5xx server error" }, { "value": "request.timeout", "label": "Request > Timeout", "description": "Triggered when the request times out" }, { "value": "request.error", "label": "Request > Error", "description": "Triggered on any error (non-2xx response)" }, { "value": "request.completed", "label": "Request > Completed", "description": "Triggered on every completed request" }, { "value": "audit.event", "label": "Audit > Event", "description": "Triggered on any audit-log entry (config changes, auth events) — for SIEM / observability streams" } ] }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` The `audit.event` type streams every audit-log entry (config changes, auth events, rotations) to your webhook — ideal for SIEM/observability pipelines. Unlike the `request.*` events, it fires in both Live and Test modes. The same stream also carries a record of proxied requests, and that record is delivered only in the request's own mode, like the `request.*` events. Each `audit.event` delivery carries the entry's **sanitised** `details`. Credential-shaped values and URL userinfo are removed before it is sent and before it is stored in your delivery log. Because the stream carries change details, creating or changing a webhook subscribed to `audit.event` needs what reading them needs: an Admin or Owner in the dashboard or on a user-bound token, or `audit_log:list` on an API key or OAuth client. Otherwise the request is refused with `403`. That covers any change to a webhook that stays subscribed, including a rename or pausing it; deleting it is not affected. An OAuth token narrowed by scope must also hold `audit-logs:read`, or it is refused with `403 insufficient_scope`. ```typescript Node.js theme={"dark"} import { KnoxCall } from "@knoxcall/sdk"; const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET const { event_types } = await client.webhooks.listEventTypes(); console.log(event_types.map((e) => e.value)); ``` ```python Python theme={"dark"} from knoxcall import KnoxCall client = KnoxCall() # credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET data = client.webhooks.list_event_types() print([e["value"] for e in data["event_types"]]) ``` ```bash cURL theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) curl https://api.knoxcall.com/v1/webhooks/event-types \ -H "Authorization: Bearer $TOKEN" ``` # Get Webhook Source: https://docs.knoxcall.com/api-reference/webhooks/get Returns the full configuration for a single webhook, including delivery statistics ## Get Webhook ```text theme={"dark"} GET /v1/webhooks/:id ``` Returns the full configuration for a single webhook, including delivery statistics. Credential material is never returned. `secret_key`, `auth_config` and `request_headers` are all accepted on create and update, and all omitted from this response. `request_headers` counts as credential material because KnoxCall spreads those headers into the outbound delivery request alongside the `Authorization` header derived from `auth_config` — a value stored there is indistinguishable from a destination API key. To change a header, send the new value on `PATCH /v1/webhooks/:id`; to stop sending one, send a `request_headers` object without it. ### Path Parameters | Parameter | Type | Description | | - | - | - | | `id` | uuid | The webhook ID | ### Response ```json theme={"dark"} { "data": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "Error Notifications", "description": "Notify Slack on 5xx errors", "url": "https://hooks.slack.com/services/T00/B00/xxx", "method": "POST", "event_types": ["request.error", "request.timeout"], "auth_type": "none", "route_filter": null, "include_request_body": true, "include_response_body": false, "include_headers": false, "timeout_seconds": 30, "retry_on_failure": true, "max_retries": 3, "enabled": true, "last_triggered_at": "2026-03-10T14:22:01.000Z", "last_success_at": "2026-03-10T14:22:01.000Z", "last_failure_at": "2026-02-28T08:15:00.000Z", "trigger_count": 142, "success_count": 140, "failure_count": 2, "created_at": "2026-01-20T10:00:00.000Z" }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ### Errors | Status | Type | Description | | - | - | - | | 404 | `not_found` | Webhook not found | # List Webhooks Source: https://docs.knoxcall.com/api-reference/webhooks/list Returns all webhooks configured in your tenant ## List Webhooks ```text theme={"dark"} GET /v1/webhooks ``` Returns all webhooks in your tenant. ### Query Parameters | Parameter | Type | Default | Description | | - | - | - | - | | `page` | integer | `1` | Page number | | `per_page` | integer | `20` | Items per page (max 100) | | `sort` | string | `created_at` | Sort by: `created_at`, `name`, or `enabled` | | `order` | string | `desc` | Sort direction: `asc` or `desc` | ### Response ```json theme={"dark"} { "data": [ { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "Error Notifications", "description": "Notify Slack on 5xx errors", "url": "https://hooks.slack.com/services/T00/B00/xxx", "method": "POST", "event_types": ["request.error", "request.timeout"], "auth_type": "none", "enabled": true, "last_triggered_at": "2026-03-10T14:22:01.000Z", "trigger_count": 142, "success_count": 140, "failure_count": 2, "created_at": "2026-01-20T10:00:00.000Z" } ], "meta": { "total": 5, "page": 1, "per_page": 20, "total_pages": 1, "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ```typescript Node.js theme={"dark"} import { KnoxCall } from "@knoxcall/sdk"; const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET const page = await client.webhooks.list({ page: 1, per_page: 20 }); const webhooks = page.data; ``` ```python Python theme={"dark"} from knoxcall import KnoxCall client = KnoxCall() # credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET page = client.webhooks.list(page=1, per_page=20) webhooks = page["data"] ``` ```bash cURL theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) curl https://api.knoxcall.com/v1/webhooks \ -H "Authorization: Bearer $TOKEN" ``` ### Errors | Status | Type | Description | | - | - | - | | 403 | `forbidden` | The API key's policy does not grant `read` on `webhook` | | 500 | `internal_error` | Failed to list webhooks | # Get Webhook Logs Source: https://docs.knoxcall.com/api-reference/webhooks/logs Returns paginated delivery logs for a specific webhook ## Get Webhook Logs ```text theme={"dark"} GET /v1/webhooks/:id/logs ``` Returns paginated delivery logs for a specific webhook, showing every delivery attempt with status and timing. ### Path Parameters | Parameter | Type | Description | | - | - | - | | `id` | uuid | The webhook ID | ### Query Parameters | Parameter | Type | Default | Description | | - | - | - | - | | `page` | integer | `1` | Page number | | `per_page` | integer | `20` | Items per page (max 100) | ### Response ```json theme={"dark"} { "data": [ { "id": "log-uuid", "http_method": "POST", "target_url": "https://hooks.example.com/services/T00/B00/xxx", "source_ip": null, "response_status": 200, "response_time_ms": 342, "executed_at": "2026-03-10T14:22:01.000Z", "success": true, "error_message": null } ], "meta": { "total": 142, "page": 1, "per_page": 20, "total_pages": 8, "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ### Errors | Status | Type | Description | | - | - | - | | 404 | `not_found` | Webhook not found | # Test Webhook Source: https://docs.knoxcall.com/api-reference/webhooks/test Fire a sample event at a webhook and report the delivery result ## Test Webhook ```text theme={"dark"} POST /v1/webhooks/{id}/test ``` Sends a synthetic `webhook.test` event to the webhook's configured URL and returns the delivery result (HTTP status and latency). Use it to confirm a webhook endpoint is reachable and that signature verification works before relying on it. The test delivery goes through the **same hardened path as real events**: the resolve-and-pin SSRF egress (a test cannot reach a private/internal address) and the same HMAC signing. The attempt is recorded in the webhook's delivery logs. Retries are disabled for the test so you get an immediate result. ### Response ```json theme={"dark"} { "data": { "success": true, "status": 200, "response_time_ms": 142 }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` When delivery fails, `success` is `false` and `error` describes why: ```json theme={"dark"} { "data": { "success": false, "response_time_ms": 5012, "error": "Request timeout" }, "meta": { "request_id": "..." } } ``` ```typescript Node.js theme={"dark"} import { KnoxCall } from "@knoxcall/sdk"; const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET const result = await client.webhooks.test("a1b2c3d4-..."); console.log(result.success ? `delivered in ${result.response_time_ms}ms` : result.error); ``` ```python Python theme={"dark"} from knoxcall import KnoxCall client = KnoxCall() # credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET result = client.webhooks.test("a1b2c3d4-...") print(f"delivered in {result['response_time_ms']}ms" if result["success"] else result["error"]) ``` ```bash cURL theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) curl -X POST https://api.knoxcall.com/v1/webhooks/a1b2c3d4-.../test \ -H "Authorization: Bearer $TOKEN" ``` ### Errors | Status | Type | Description | | - | - | - | | 404 | `not_found` | No webhook with that ID in the current mode (Live/Sandbox) | | 403 | `permission_denied` | Tenant policy denies the `test` action on `webhook` | # Update Webhook Source: https://docs.knoxcall.com/api-reference/webhooks/update Update one or more fields on an existing webhook ## Update Webhook ```text theme={"dark"} PATCH /v1/webhooks/:id ``` Updates one or more fields on an existing webhook. ### Path Parameters | Parameter | Type | Description | | - | - | - | | `id` | uuid | The webhook ID | ### Request Body All fields are optional. Only provided fields will be updated. | Field | Type | Description | | - | - | - | | `name` | string | Display name | | `description` | string | Description | | `url` | string | Delivery URL | | `method` | string | HTTP method | | `event_types` | string\[] | Subscribed event types | | `auth_type` | string | Authentication type: `none`, `basic`, `bearer`, `hmac`, `header` | | `auth_config` | object | Authentication credentials | | `request_headers` | object | Custom delivery headers | | `route_filter` | string\[] | Array of route UUIDs to filter events by | | `include_request_body` | boolean | Include request body | | `include_response_body` | boolean | Include response body | | `include_headers` | boolean | Include headers | | `timeout_seconds` | integer | Delivery timeout | | `retry_on_failure` | boolean | Retry on failure | | `max_retries` | integer | Max retry attempts | | `enabled` | boolean | Enable or disable | | `hmac_key_id` | uuid | Crypto Keys transit key for HMAC signing | | `hmac_format` | string | Signature format (`legacy`, `stripe`, `github`, `slack`, `aws-sns`, `custom`) | | `hmac_header_name` | string | Custom header name when `hmac_format` is `custom` | ### Response Returns `{ "data": { ... }, "meta": { ... } }` — the updated webhook fields: `id`, `name`, `description`, `url`, `method`, `event_types`, `auth_type`, `enabled`, `created_at`. The `secret_key` is never returned after creation. ### Errors | Status | Type | Description | | - | - | - | | 400 | `validation_error` | No valid fields provided | | 404 | `not_found` | Webhook not found | # AWS (Lambda, ECS, EC2, IRSA) Source: https://docs.knoxcall.com/api-reference/workload-identity/aws Authenticate from AWS workloads to KnoxCall using IAM Roles for Service Accounts (IRSA) or static client credentials # AWS Workload Identity KnoxCall accepts AWS-issued OIDC tokens from EKS pods (IRSA) and other workloads with `AWS_WEB_IDENTITY_TOKEN_FILE` configured. STS workload identity for ECS / Lambda is supported via the same pattern. ## 1. Set up the IAM role + EKS service account (IRSA) Existing AWS IAM setup — your cluster's OIDC provider must be registered. See [AWS docs](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html). Once configured, your pod has these env vars automatically: * `AWS_WEB_IDENTITY_TOKEN_FILE` (path to a file containing a signed JWT) * `AWS_ROLE_ARN` ## 2. Configure the KnoxCall binding Create the workload binding in the dashboard with **Settings → API → Workload Identity → Connect workload**, or call the admin API directly. The `/admin/*` routes are served on the admin host (`admin.knoxcall.com` / any `knoxcall.com` host) and are authenticated by your logged-in admin/owner session — a session JWT plus the `X-Tenant-ID` header — not an API key against `api.knoxcall.com`. Creating a binding requires a **recent step-up verification** with a second factor (passkey, security key or authenticator-app code; an emailed code does not count), made within the last 5 minutes **for this action**: it must carry `"action_scope": "oauth.client"`. A binding is a trust that lets an external workload mint tenant tokens, so it carries the same bar as creating an OAuth client. A `curl` carrying only a session JWT answers `403` with `"requires_step_up": true`, `"action_scope": "oauth.client"` and `"allowed_methods": ["passkey", "2fa"]`. On the same session, first call `POST /auth/2fa/verify-step-up` with `{"totp_code": "", "action_scope": "oauth.client"}`, or complete a WebAuthn step-up in a browser with the same `action_scope` (`POST /auth/passkey/verify-step-up` for a passkey, or `POST /auth/2fa/webauthn/step-up-options` then `POST /auth/2fa/webauthn/verify-step-up` for a security key), then replay the request inside the 5-minute window. A verification made for a different action, such as one a Dashboard prompt raised, carries that action's scope and does not satisfy this gate. Each verification is single-use, so a retried request needs a fresh one. Listing and revoking bindings need no verification: containment must never be gated. ```bash theme={"dark"} curl -X POST https://admin.knoxcall.com/admin/oauth/workload-bindings \ -H "Authorization: Bearer $SESSION_JWT" \ -H "X-Tenant-ID: $TENANT_ID" \ -H "Content-Type: application/json" \ -d '{ "oauth_client_id": "", "name": "aws-prod-eks", "issuer": "https://sts.amazonaws.com", "audience": "knoxcall:api", "attribute_conditions": { "arn": "arn:aws:iam::123456789012:role/knoxcall-prod" }, "allowed_scopes": ["routes:write"], "access_token_ttl_seconds": 3600 }' ``` Lock attribute conditions to the assumed IAM role ARN. The OIDC token from `sts.amazonaws.com` carries the role ARN in the `arn` claim. ## 3. Call KnoxCall ```javascript theme={"dark"} import { KnoxCall } from "@knoxcall/sdk"; const client = new KnoxCall({ tenant: "acme" }); await client.routes.list(); ``` The SDK reads `AWS_WEB_IDENTITY_TOKEN_FILE`, exchanges the token for a KnoxCall access token, and uses it. No secrets stored. ## Lambda / EC2 without IRSA — static client credentials If you're on Lambda or EC2 without IRSA, the SDK falls back to plain `client_credentials`: the static `KNOXCALL_CLIENT_ID` + `KNOXCALL_CLIENT_SECRET` env vars. There is no IMDSv2 / EC2 instance-identity path — the SDK only reads `AWS_WEB_IDENTITY_TOKEN_FILE` for the OIDC flow and otherwise uses these env vars. Store them in AWS Secrets Manager or Lambda environment variables encrypted with a KMS key. For Lambda with IRSA via container images, the IRSA env vars are set automatically by the Lambda runtime — same SDK works. ## Troubleshooting * **`Could not detect KnoxCall credentials`** — `AWS_WEB_IDENTITY_TOKEN_FILE` is unset. Verify the pod's service account is annotated with `eks.amazonaws.com/role-arn`. * **`invalid_grant: no binding matched`** — decode the JWT (`cat $AWS_WEB_IDENTITY_TOKEN_FILE | cut -d. -f2 | base64 -d`) and compare its `arn` claim to your binding. # Azure (Functions, App Service, AKS, VMs) Source: https://docs.knoxcall.com/api-reference/workload-identity/azure Authenticate from Azure workloads to KnoxCall using Managed Identity # Azure Workload Identity Azure workloads with a system-assigned or user-assigned Managed Identity get a local metadata endpoint that issues short-lived tokens. KnoxCall accepts these via RFC 8693. ## 1. Enable Managed Identity on your resource Azure portal: **Identity → System assigned: On** (or attach a user-assigned identity). This sets the env vars on every request: * `IDENTITY_ENDPOINT` — local metadata URL * `IDENTITY_HEADER` — header value the metadata service requires ## 2. Configure the trust binding Create the binding in the dashboard with **Settings → API → Workload Identity → Connect workload**, or call the admin API on the admin host (`admin.knoxcall.com`). The admin API is authenticated by your dashboard session JWT plus an `X-Tenant-ID` header (it is **not** the API-key data plane on `api.knoxcall.com`). Creating a binding requires a **recent step-up verification** with a second factor (passkey, security key or authenticator-app code; an emailed code does not count), made within the last 5 minutes **for this action**: it must carry `"action_scope": "oauth.client"`. A binding is a trust that lets an external workload mint tenant tokens, so it carries the same bar as creating an OAuth client. A `curl` carrying only a session JWT answers `403` with `"requires_step_up": true`, `"action_scope": "oauth.client"` and `"allowed_methods": ["passkey", "2fa"]`. On the same session, first call `POST /auth/2fa/verify-step-up` with `{"totp_code": "", "action_scope": "oauth.client"}`, or complete a WebAuthn step-up in a browser with the same `action_scope` (`POST /auth/passkey/verify-step-up` for a passkey, or `POST /auth/2fa/webauthn/step-up-options` then `POST /auth/2fa/webauthn/verify-step-up` for a security key), then replay the request inside the 5-minute window. A verification made for a different action, such as one a Dashboard prompt raised, carries that action's scope and does not satisfy this gate. Each verification is single-use, so a retried request needs a fresh one. Listing and revoking bindings need no verification: containment must never be gated. ```bash theme={"dark"} curl -X POST https://admin.knoxcall.com/admin/oauth/workload-bindings \ -H "Authorization: Bearer $SESSION_JWT" \ -H "X-Tenant-ID: $TENANT_ID" \ -H "Content-Type: application/json" \ -d '{ "oauth_client_id": "", "name": "azure-prod-functions", "issuer": "https://login.microsoftonline.com", "audience": "knoxcall:api", "attribute_conditions": { "oid": "" }, "allowed_scopes": ["routes:read"], "access_token_ttl_seconds": 3600 }' ``` Lock to the Managed Identity object id (`oid` claim). You can also lock `tid` (tenant) or `appid` for tighter control. ## 3. Call KnoxCall ```javascript theme={"dark"} import { KnoxCall } from "@knoxcall/sdk"; const client = new KnoxCall({ tenant: "acme" }); await client.routes.list(); ``` The SDK calls `$IDENTITY_ENDPOINT?resource=knoxcall:api` with the `X-IDENTITY-HEADER`, gets the JWT, exchanges it for a KnoxCall token. ## Troubleshooting * **`Could not detect KnoxCall credentials`** — Managed Identity isn't enabled or env vars aren't propagated. Check the resource's Identity blade. * **`invalid_target`** — `resource` param in the metadata call must be exactly `knoxcall:api`; we set this automatically. * **`invalid_grant: no binding matched`** — verify the `oid` of your Managed Identity (run `az identity show --ids --query principalId`) matches the binding. # Google Cloud (GCE, GKE, Cloud Run, Cloud Functions) Source: https://docs.knoxcall.com/api-reference/workload-identity/gcp Authenticate from any GCP workload to KnoxCall using the GCE metadata service # Google Cloud Workload Identity Every Google Cloud workload has a metadata service at `metadata.google.internal` that issues short-lived OIDC tokens signed by Google. KnoxCall accepts these tokens via RFC 8693 token exchange. ## 1. Configure the trust binding Create the workload binding in the dashboard with **Settings → API → Workload Identity → Connect workload**, or call the admin API directly. The `/admin/*` routes are served on the admin host (`admin.knoxcall.com` / any `knoxcall.com` host) and are authenticated by your logged-in admin/owner session — a session JWT plus the `X-Tenant-ID` header — not an API key against `api.knoxcall.com`. Creating a binding requires a **recent step-up verification** with a second factor (passkey, security key or authenticator-app code; an emailed code does not count), made within the last 5 minutes **for this action**: it must carry `"action_scope": "oauth.client"`. A binding is a trust that lets an external workload mint tenant tokens, so it carries the same bar as creating an OAuth client. A `curl` carrying only a session JWT answers `403` with `"requires_step_up": true`, `"action_scope": "oauth.client"` and `"allowed_methods": ["passkey", "2fa"]`. On the same session, first call `POST /auth/2fa/verify-step-up` with `{"totp_code": "", "action_scope": "oauth.client"}`, or complete a WebAuthn step-up in a browser with the same `action_scope` (`POST /auth/passkey/verify-step-up` for a passkey, or `POST /auth/2fa/webauthn/step-up-options` then `POST /auth/2fa/webauthn/verify-step-up` for a security key), then replay the request inside the 5-minute window. A verification made for a different action, such as one a Dashboard prompt raised, carries that action's scope and does not satisfy this gate. Each verification is single-use, so a retried request needs a fresh one. Listing and revoking bindings need no verification: containment must never be gated. ```bash theme={"dark"} curl -X POST https://admin.knoxcall.com/admin/oauth/workload-bindings \ -H "Authorization: Bearer $SESSION_JWT" \ -H "X-Tenant-ID: $TENANT_ID" \ -H "Content-Type: application/json" \ -d '{ "oauth_client_id": "", "name": "gcp-prod-api", "issuer": "https://accounts.google.com", "audience": "knoxcall:api", "attribute_conditions": { "email": "knoxcall-prod@my-project.iam.gserviceaccount.com" }, "allowed_scopes": ["routes:write", "secrets:read"], "access_token_ttl_seconds": 3600 }' ``` Lock to the service account `email` claim, or to its numeric `sub`. A rule must pin one of the two: `https://accounts.google.com` signs tokens for every Google account, so a rule without an identifying claim would match other projects' service accounts, and KnoxCall refuses it with a `400`. Conditions compare **top-level claims by exact string match**. Two conditions that look reasonable do not work today, so do not add them: * `email_verified` — Google sends it as a boolean, and a condition value is a string, so the rule would never match. * `google.compute_engine.project_id` / `instance_id` — these sit inside a nested `google` object, and a dotted key is not a path. Instance-level binding is not supported yet; use one service account per workload you want to tell apart. ## 2. Run with the right service account ### Cloud Run / Cloud Functions ```bash theme={"dark"} gcloud run deploy my-service \ --image gcr.io/my-project/my-service \ --service-account knoxcall-prod@my-project.iam.gserviceaccount.com \ --set-env-vars KNOXCALL_TENANT=acme ``` ### GKE Workload Identity ```yaml theme={"dark"} apiVersion: v1 kind: ServiceAccount metadata: name: knoxcall-caller annotations: iam.gke.io/gcp-service-account: knoxcall-prod@my-project.iam.gserviceaccount.com ``` ```yaml theme={"dark"} spec: template: spec: serviceAccountName: knoxcall-caller containers: - env: - { name: KNOXCALL_TENANT, value: acme } ``` ### GCE The VM's default or attached service account is used automatically. ## 3. Call KnoxCall ```javascript theme={"dark"} import { KnoxCall } from "@knoxcall/sdk"; const client = new KnoxCall({ tenant: process.env.KNOXCALL_TENANT }); const routes = await client.routes.list(); ``` The SDK hits `http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=knoxcall:api`, fetches the signed token, and exchanges it for a KnoxCall access token. ## Troubleshooting * **`Could not detect KnoxCall credentials`** — the metadata service isn't reachable. This is normal locally; set `KNOXCALL_CLIENT_ID` + `KNOXCALL_CLIENT_SECRET` for local dev. * **`invalid_grant: no binding matched`** — verify the service account email matches what's in the binding. Run `gcloud auth print-identity-token --audiences=knoxcall:api` on the host and decode it. * **Network egress** — if your GKE pod has restricted egress, allow `metadata.google.internal:80`. # GitHub Actions Source: https://docs.knoxcall.com/api-reference/workload-identity/github-actions Authenticate from GitHub Actions to KnoxCall with zero stored secrets via OIDC workload identity federation # GitHub Actions Workload Identity GitHub Actions can exchange its workflow OIDC token for a KnoxCall access token via RFC 8693 — no secrets stored in the repo, organization, or environment. ## 1. Add the workflow permission In your `.github/workflows/*.yml`: ```yaml theme={"dark"} permissions: id-token: write # required to mint the GHA OIDC token contents: read # standard ``` ## 2. Configure the trust binding in KnoxCall In the dashboard, open **Settings → API → Workload Identity → Connect workload** — the wizard checks your issuer, builds the rule, and lets you test a real token before your first run — or call the admin API directly. The `/admin/*` routes are served on the admin host (`admin.knoxcall.com` / any `knoxcall.com` host) and are authenticated by your logged-in admin/owner session — a session JWT plus the `X-Tenant-ID` header — not an API key against `api.knoxcall.com`. Creating a binding requires a **recent step-up verification** with a second factor (passkey, security key or authenticator-app code; an emailed code does not count), made within the last 5 minutes **for this action**: it must carry `"action_scope": "oauth.client"`. A binding is a trust that lets an external workload mint tenant tokens, so it carries the same bar as creating an OAuth client. A `curl` carrying only a session JWT answers `403` with `"requires_step_up": true`, `"action_scope": "oauth.client"` and `"allowed_methods": ["passkey", "2fa"]`. On the same session, first call `POST /auth/2fa/verify-step-up` with `{"totp_code": "", "action_scope": "oauth.client"}`, or complete a WebAuthn step-up in a browser with the same `action_scope` (`POST /auth/passkey/verify-step-up` for a passkey, or `POST /auth/2fa/webauthn/step-up-options` then `POST /auth/2fa/webauthn/verify-step-up` for a security key), then replay the request inside the 5-minute window. A verification made for a different action, such as one a Dashboard prompt raised, carries that action's scope and does not satisfy this gate. Each verification is single-use, so a retried request needs a fresh one. Listing and revoking bindings need no verification: containment must never be gated. ```bash theme={"dark"} curl -X POST https://admin.knoxcall.com/admin/oauth/workload-bindings \ -H "Authorization: Bearer $SESSION_JWT" \ -H "X-Tenant-ID: $TENANT_ID" \ -H "Content-Type: application/json" \ -d '{ "oauth_client_id": "", "name": "gha-acme-api-deploy", "issuer": "https://token.actions.githubusercontent.com", "audience": "knoxcall:api", "attribute_conditions": { "repository": "acme/api", "ref": "refs/heads/main", "workflow": "deploy" }, "allowed_scopes": ["routes:write", "secrets:read"], "access_token_ttl_seconds": 3600 }' ``` **Attribute conditions are required.** They prevent any caller on `token.actions.githubusercontent.com` (i.e., any GitHub repository in the world) from minting tokens for your tenant. Always lock down at minimum `repository`; lock to `workflow` + `ref` for production paths. **`allowed_scopes` is required and may not be empty.** A binding with no scopes is not a binding that grants nothing — a token with an empty scope is treated as *unnarrowed*, so it would reach every endpoint your tenant has. Creating one is refused with `400 invalid_allowed_scopes`, and a binding that somehow holds an empty list refuses to exchange. List exactly the scopes the workflow needs. ## 3. Call KnoxCall from the workflow ```yaml theme={"dark"} jobs: deploy: runs-on: ubuntu-latest permissions: id-token: write contents: read steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 20 } - run: npm install @knoxcall/sdk@^1.0.0 - run: node deploy.js env: KNOXCALL_TENANT: acme ``` In `deploy.js`: ```javascript theme={"dark"} import { KnoxCall } from "@knoxcall/sdk"; const client = new KnoxCall({ tenant: process.env.KNOXCALL_TENANT }); const routes = await client.routes.list(); ``` The SDK auto-detects `ACTIONS_ID_TOKEN_REQUEST_URL` + `ACTIONS_ID_TOKEN_REQUEST_TOKEN`, fetches the OIDC token, exchanges it for a 1-hour KnoxCall access token, and uses it for all API calls. Zero stored secrets anywhere in your repo or organization. ## What's in the OIDC token GitHub puts these claims in every workflow OIDC token; you can match on any of them in `attribute_conditions`: | Claim | Example | Use for | | - | - | - | | `repository` | `acme/api` | Always include — locks the binding to one repo | | `repository_owner` | `acme` | Permit any repo under an org | | `ref` | `refs/heads/main` | Permit production branch only | | `ref_type` | `branch` | Block tag pushes | | `event_name` | `push` / `pull_request` / `workflow_dispatch` | Block PRs from forks | | `workflow` | `deploy` | Permit one workflow only | | `job_workflow_ref` | `acme/api/.github/workflows/deploy.yml@refs/heads/main` | Strictest match | | `environment` | `production` | Permit only protected environments | | `sub` | `repo:acme/api:ref:refs/heads/main` | Composite — useful when you want exact match | ## Recommendations * For production paths: require `environment` + `ref` + `repository`. GitHub Environments add a manual-approval gate you should leverage. * For PR / CI paths: require `event_name != pull_request` to avoid third-party fork PRs minting tokens. * Use one binding per logical workload, not one per repo. Granular bindings make `last_used_at` data actually useful. ## Troubleshooting * **`invalid_grant: no binding matched`** — JWT attributes don't match. Fetch and decode the OIDC token to inspect its claims and compare them to your binding: ```bash theme={"dark"} curl -s -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=knoxcall:api" \ | python3 -c 'import sys,json,base64; t=json.load(sys.stdin)["value"].split("."); print(base64.urlsafe_b64decode(t[1]+"=="*(-len(t[1])%4)).decode())' ``` * **`invalid_request: subject_token signature invalid`** — Audience mismatch. The SDK appends `&audience=knoxcall:api` to the OIDC request — make sure your binding's `audience` field is exactly `knoxcall:api`. * **`invalid_request: subject_token expired`** — Clock skew. Ensure your runner's clock is sane (NTP). # HCP Terraform Source: https://docs.knoxcall.com/api-reference/workload-identity/hcp-terraform Authenticate a Terraform run to KnoxCall with zero stored secrets, using HCP Terraform workload identity and RFC 8693 token exchange # HCP Terraform Workload Identity An HCP Terraform run can exchange its own workload identity token for a short-lived KnoxCall access token — no KnoxCall API key stored as a workspace variable, and nothing to rotate. HCP Terraform is one of the three issuers KnoxCall knows by name, so a binding here is matched on `sub` rather than falling back to a generic rule. `sub` carries the organisation, project, workspace **and run phase**, which is what makes the strongest recommendation on this page possible: a `plan` can be given different access from an `apply`. Unlike GitHub Actions, Google Cloud or AWS, the KnoxCall SDKs do **not** auto-detect an HCP Terraform run — the token is handed to you in an environment variable, so you pass it to the exchange yourself. Everything on this page uses that variable directly. ## 1. Turn on workload identity in the workspace Set a workspace variable — **Workspace → Variables → Environment variable**: | Variable | Value | | - | - | | `TFC_WORKLOAD_IDENTITY_AUDIENCE` | `knoxcall:api` | Once it is set, every plan and apply gets a `TFC_WORKLOAD_IDENTITY_TOKEN` variable in its run environment containing a signed OIDC token for that audience. If the workspace already uses workload identity for a cloud provider, do not overwrite the existing audience — add a second one instead. `TFC_WORKLOAD_IDENTITY_AUDIENCE_KNOXCALL` produces `TFC_WORKLOAD_IDENTITY_TOKEN_KNOXCALL` alongside it, and the tag may contain only letters, numbers and underscores. ## 2. Register the binding in KnoxCall Dashboard → **Settings → API → Workload Identity**, or call the admin API. The `/admin/*` routes are served on the admin host and authenticated by your logged-in admin/owner session — a session JWT plus the `X-Tenant-ID` header — not an API key against `api.knoxcall.com`. Creating a binding requires a **recent step-up verification** with a second factor (passkey, security key or authenticator-app code; an emailed code does not count), made within the last 5 minutes with `"action_scope": "oauth.client"`: a binding is a trust that lets an external workload mint tenant tokens, so it carries the same bar as creating an OAuth client. A `curl` carrying only a session JWT answers `403` with `"requires_step_up": true`, `"action_scope": "oauth.client"` and `"allowed_methods": ["passkey", "2fa"]`. On the same session, first call `POST /auth/2fa/verify-step-up` with `{"totp_code": "", "action_scope": "oauth.client"}` (or a WebAuthn step-up with the same scope: `POST /auth/passkey/verify-step-up`, or `POST /auth/2fa/webauthn/verify-step-up` for a security key), then replay inside the window. A verification made for another action, such as one a Dashboard prompt raised, does not satisfy this gate. Each verification is single-use. Listing and revoking bindings need no verification; containment is never gated. ```bash theme={"dark"} curl -X POST https://admin.knoxcall.com/admin/oauth/workload-bindings \ -H "Authorization: Bearer $SESSION_JWT" \ -H "X-Tenant-ID: $TENANT_ID" \ -H "Content-Type: application/json" \ -d '{ "oauth_client_id": "", "name": "tfc-platform-prod-apply", "issuer": "https://app.terraform.io", "audience": "knoxcall:api", "attribute_conditions": { "sub": "organization:acme:project:platform:workspace:prod:run_phase:apply" }, "allowed_scopes": ["routes:write", "secrets:read"], "access_token_ttl_seconds": 3600 }' ``` **`attribute_conditions` is required**, and for this issuer it must pin `sub`. Without it the binding would describe every workspace in every organisation on `app.terraform.io`, not yours. **`allowed_scopes` is required and may not be empty.** An empty allow-list is not "no access" — a token with no scopes is treated as *unnarrowed*, so it would reach every endpoint your tenant has. Creating one is refused with `400 invalid_allowed_scopes`. ## 3. Exchange the token inside the run ```hcl theme={"dark"} data "http" "knoxcall_token" { url = "https://api.knoxcall.com/oauth/token" method = "POST" request_headers = { Content-Type = "application/x-www-form-urlencoded" } request_body = join("&", [ "grant_type=urn:ietf:params:oauth:grant-type:token-exchange", "subject_token_type=urn:ietf:params:oauth:token-type:id_token", "subject_token=${urlencode(var.tfc_workload_identity_token)}", ]) } ``` …with the token passed in as a variable: ```hcl theme={"dark"} variable "tfc_workload_identity_token" { type = string sensitive = true # Populated from the run environment: TF_VAR_tfc_workload_identity_token, # or read the TFC_WORKLOAD_IDENTITY_TOKEN environment variable in a wrapper. } ``` Or, from a `local-exec` / CI wrapper around the run: ```bash theme={"dark"} KNOX_TOKEN=$(curl -sS https://api.knoxcall.com/oauth/token \ -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \ -d subject_token_type=urn:ietf:params:oauth:token-type:id_token \ -d subject_token="$TFC_WORKLOAD_IDENTITY_TOKEN" | jq -r .access_token) ``` The response is a standard OAuth token response; `access_token` is a `kc_` token valid for the binding's TTL, capped at twice the remaining life of the Terraform token. ## What's in the token | Claim | Example | Use for | | - | - | - | | `sub` | `organization:acme:project:platform:workspace:prod:run_phase:apply` | **Required.** Organisation, project, workspace and phase in one string | | `terraform_organization_name` | `acme` | Readability in a rule that also pins `sub` | | `terraform_project_name` | `platform` | As above | | `terraform_workspace_name` | `prod` | As above | | `terraform_full_workspace` | `organization:acme:project:platform:workspace:prod` | The workspace without the run phase | | `terraform_run_id` | `run-CZcmD7eagjhyX0vN` | Never pin this — it changes every run | | `terraform_run_phase` | `apply` | `plan` or `apply` | A module test run carries a different `sub` shape — `organization:{ORG}:module:{MODULE}:operation:test_run` — and always reports `terraform_run_phase: plan`. ## Recommendations * **Separate the phases.** Pin `run_phase:apply` on the binding that carries write scopes, and register a second, read-only binding for `run_phase:plan` if plans need KnoxCall access at all. A speculative plan can be triggered by a pull request from someone who cannot merge it; without the phase in the rule, that plan mints the same token your apply does. * **One binding per workspace**, not one per organisation. `sub` names the workspace, so a per-workspace rule costs nothing and makes the exchange history per-workspace too. * Keep `access_token_ttl_seconds` near the length of a real apply. KnoxCall already caps the minted token at twice the remaining life of the Terraform token, so a long TTL mostly does nothing. ## Troubleshooting * **`invalid_grant: no binding matched the subject_token's claims`** — the `sub` in the rule is not the `sub` in the token. Decode the token in a run to compare: ```bash theme={"dark"} echo "$TFC_WORKLOAD_IDENTITY_TOKEN" | cut -d. -f2 \ | base64 -d 2>/dev/null | jq . ``` The usual causes are a renamed workspace, a project the workspace was moved into, and a rule written for `apply` while the failure is on `plan`. * **`invalid_request: Unsupported audience`** — `TFC_WORKLOAD_IDENTITY_AUDIENCE` is not `knoxcall:api`. The audience is baked into the token at issue time, so it must match before the run starts. * **`invalid_grant: subject_token has already been exchanged`** — each token is single-use. A retried apply needs the token from its own run; re-using a captured one is exactly what that refusal exists to stop. * **`403 {"requires_step_up": true}` when creating the binding** — see the note in step 2. # Kubernetes (self-managed, EKS, GKE, AKS) Source: https://docs.knoxcall.com/api-reference/workload-identity/kubernetes Authenticate from a Kubernetes pod to KnoxCall using its projected service-account token — including clusters whose issuer is unreachable from the internet # Kubernetes Workload Identity Every pod can be given a **projected service-account token**: a short-lived OIDC token, signed by the cluster, that names the service account it was issued to. KnoxCall accepts it via RFC 8693 token exchange, so a pod authenticates with no stored KnoxCall credential at all. The part that makes Kubernetes different from GitHub Actions or GCP is **where your cluster publishes its signing keys**. ## Which setup do you have? Most self-managed clusters land in the second row. If you are not sure, run `kubectl get --raw /.well-known/openid-configuration` and look at the `jwks_uri` it returns — then ask whether that URL is reachable from the public internet. | Your cluster | JWKS source to choose | | - | - | | A managed cluster with a **public** OIDC issuer (EKS with an IAM OIDC provider, GKE Workload Identity, AKS with a public issuer) | **Discovery** — the default. Nothing extra to configure. | | A **self-managed** cluster, or any cluster whose issuer is only reachable inside the network (`https://kubernetes.default.svc`) | **Inline** — paste the cluster's public keys. KnoxCall never makes a network call for them. | | An issuer whose keys are published at a public URL, but whose discovery document is missing or wrong | **Explicit URL** | `https://kubernetes.default.svc` is the default issuer string for *every* cluster, so it is not unique to you. KnoxCall stores your keys against your tenant and your issuer together, and an assertion is only ever verified against **your** cluster's keys — a different customer registering the same issuer string cannot mint into your tenant, and you cannot mint into theirs. ## 1. Get your cluster's issuer and keys ```bash theme={"dark"} # The issuer string your tokens will carry in `iss` kubectl get --raw /.well-known/openid-configuration | jq -r .issuer # The public keys, as a JWKS document kubectl get --raw /openid/v1/jwks ``` The second command prints something of the form `{"keys":[{...}]}`. That whole document is what you paste for **Inline**. It contains **public** keys only — there is no private key material in it. ## 2. Configure the issuer's JWKS source For discovery clusters, skip this step. Changing an issuer's JWKS source requires a **recent step-up verification** with a second factor (passkey, security key or authenticator-app code; an emailed code does not count), made within the last 5 minutes with `"action_scope": "oauth.client"`. Pasting keys is telling KnoxCall which signer to believe for your tenant, so it carries the same bar as creating a binding. A request carrying only a session JWT answers `403` with `"requires_step_up": true`, `"action_scope": "oauth.client"` and `"allowed_methods": ["passkey", "2fa"]`. On the same session, first call `POST /auth/2fa/verify-step-up` with `{"totp_code": "", "action_scope": "oauth.client"}` (or a WebAuthn step-up with the same scope: `POST /auth/passkey/verify-step-up`, or `POST /auth/2fa/webauthn/verify-step-up` for a security key), then retry. A verification made for another action does not satisfy this gate. ```bash theme={"dark"} curl -X POST https://admin.knoxcall.com/admin/federation/issuers/jwks-source \ -H "Authorization: Bearer $SESSION_JWT" \ -H "X-Tenant-ID: $TENANT_ID" \ -H "Content-Type: application/json" \ -d '{ "issuer": "https://kubernetes.default.svc", "mode": "inline", "jwks": { "keys": [ { "kty": "RSA", "kid": "…", "n": "…", "e": "AQAB" } ] } }' ``` The request is **rejected** if the document cannot produce a usable key — KnoxCall parses it before saving rather than letting you discover the problem on your first exchange. For `inline` this check makes no network call at all. **Inline keys do not refresh.** When your cluster rotates its service-account signing keys, exchanges stop verifying until you paste the new document. KnoxCall names that cause specifically rather than reporting a generic signature failure, and the binding's status in the Dashboard shows it — but nothing rotates them for you. If your cluster rotates on a schedule, put this on the same schedule. ## 3. Create the workload binding Bind the service accounts you want to trust. A Kubernetes `sub` looks like: ``` system:serviceaccount:: ``` ```bash theme={"dark"} curl -X POST https://admin.knoxcall.com/admin/oauth/workload-bindings \ -H "Authorization: Bearer $SESSION_JWT" \ -H "X-Tenant-ID: $TENANT_ID" \ -H "Content-Type: application/json" \ -d '{ "issuer": "https://kubernetes.default.svc", "name": "prod deployer", "attribute_conditions": { "sub": "system:serviceaccount:prod:deployer" }, "allowed_scopes": ["secrets:read"] }' ``` You can cover a whole namespace with a trailing `*`: ```json theme={"dark"} { "sub": "system:serviceaccount:prod:*" } ``` On an **inline** issuer a broad prefix is accepted, because the issuer is already the boundary — only your cluster's signing key produces a token that verifies at all, so `system:serviceaccount:*` grants nothing your cluster could not grant itself. On a shared public issuer the same prefix is refused, because there it would match other people's workloads. A bare `"*"` is always refused: it names no workload. ## 4. Project the token into your pod ```yaml theme={"dark"} apiVersion: v1 kind: Pod spec: serviceAccountName: deployer containers: - name: app image: your/image env: - name: KNOXCALL_TENANT value: acme volumeMounts: - name: knoxcall-token mountPath: /var/run/secrets/knoxcall readOnly: true volumes: - name: knoxcall-token projected: sources: - serviceAccountToken: path: token # The audience MUST match the binding's audience. audience: knoxcall:api # Short is good: the minted token can never outlive the assertion. expirationSeconds: 600 ``` Set `audience:` explicitly. A projected token with the cluster's default audience will not match your binding, and the exchange refuses it — correctly, but the message reads like a credential problem rather than a manifest one. ## 5. Exchange it The token file is re-read **before every exchange**, never once at startup: KnoxCall assertions are single-use, so replaying the same bytes is refused. The SDKs' `WorkloadCredentialProvider` does this for you, including refreshing before expiry. ```javascript theme={"dark"} import { WorkloadCredentialProvider } from '@knoxcall/sdk'; import { readFile } from 'node:fs/promises'; const provider = new WorkloadCredentialProvider({ tenant: process.env.KNOXCALL_TENANT, // Re-read on EVERY call — kubelet rewrites this file as it rotates the token. assertion: () => readFile('/var/run/secrets/knoxcall/token', 'utf8'), }); const token = await provider.getAccessToken(); ``` Doing it by hand is one request: ```bash theme={"dark"} curl -X POST https://api.knoxcall.com/oauth/token \ -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \ -d subject_token_type=urn:ietf:params:oauth:token-type:id_token \ -d audience=knoxcall:api \ --data-urlencode subject_token@/var/run/secrets/knoxcall/token ``` ## Troubleshooting | What you see | What it means | | - | - | | `subject_token has no kid` | The JWS header names no key. Every mainstream cluster sets it; if yours does not, it is a signing configuration option. | | `no binding matched the subject_token's claims` | The `sub` does not match any binding — check the namespace and service-account name, and that `audience:` in the manifest matches the binding. | | `the issuer publishes no signing keys KnoxCall can use` | For inline, the pasted document had no importable key. Re-run `kubectl get --raw /openid/v1/jwks`. | | Exchanges that used to work now fail to verify | On an **inline** issuer, your cluster has rotated its keys. Paste the current JWKS. | | `subject_token has already been exchanged` | The same token bytes were sent twice. Re-read the file before each exchange rather than caching its contents. | # Vercel Source: https://docs.knoxcall.com/api-reference/workload-identity/vercel Authenticate from Vercel functions to KnoxCall using Vercel OIDC tokens # Vercel Workload Identity Vercel issues per-deployment OIDC tokens (`VERCEL_OIDC_TOKEN`) to every function. KnoxCall accepts them via RFC 8693 token exchange. ## 1. Enable OIDC for your Vercel project Vercel project → **Settings → OIDC Federation → Enable**. The `VERCEL_OIDC_TOKEN` env var is then injected into every deployment. ## 2. Configure the trust binding Create the binding in the dashboard with **Settings → API → Workload Identity → Connect workload**, or call the admin API on the admin host (`admin.knoxcall.com`). The `/admin/*` routes are authenticated by your logged-in admin/owner session — a session JWT plus the `X-Tenant-ID` header — not an API key against `api.knoxcall.com`. Creating a binding requires a **recent step-up verification** with a second factor (passkey, security key or authenticator-app code; an emailed code does not count), made within the last 5 minutes **for this action**: it must carry `"action_scope": "oauth.client"`. A binding is a trust that lets an external workload mint tenant tokens, so it carries the same bar as creating an OAuth client. A `curl` carrying only a session JWT answers `403` with `"requires_step_up": true`, `"action_scope": "oauth.client"` and `"allowed_methods": ["passkey", "2fa"]`. On the same session, first call `POST /auth/2fa/verify-step-up` with `{"totp_code": "", "action_scope": "oauth.client"}`, or complete a WebAuthn step-up in a browser with the same `action_scope` (`POST /auth/passkey/verify-step-up` for a passkey, or `POST /auth/2fa/webauthn/step-up-options` then `POST /auth/2fa/webauthn/verify-step-up` for a security key), then replay the request inside the 5-minute window. A verification made for a different action, such as one a Dashboard prompt raised, carries that action's scope and does not satisfy this gate. Each verification is single-use, so a retried request needs a fresh one. Listing and revoking bindings need no verification: containment must never be gated. ```bash theme={"dark"} curl -X POST https://admin.knoxcall.com/admin/oauth/workload-bindings \ -H "Authorization: Bearer $SESSION_JWT" \ -H "X-Tenant-ID: $TENANT_ID" \ -H "Content-Type: application/json" \ -d '{ "oauth_client_id": "", "name": "vercel-acme-prod", "issuer": "https://oidc.vercel.com", "audience": "knoxcall:api", "attribute_conditions": { "owner": "acme", "project": "api", "environment": "production" }, "allowed_scopes": ["routes:read", "secrets:read"], "access_token_ttl_seconds": 3600 }' ``` Lock to your Vercel org (`owner`), project, and environment. The token also carries `aud`, `iss`, `iat`, `exp`, plus deployment metadata (`deployment_id`, `git_commit_sha`). ## 3. Call KnoxCall from your function ```javascript theme={"dark"} // api/list-routes.js (Vercel Edge or Node function) import { KnoxCall } from "@knoxcall/sdk"; export default async function handler(req, res) { const client = new KnoxCall({ tenant: process.env.KNOXCALL_TENANT }); const routes = await client.routes.list(); res.json(routes); } ``` ## Recommendations * Use separate bindings for `preview` vs `production` environments — preview deployments shouldn't be able to mint production tokens. * Limit `allowed_scopes` to what the function actually needs. ## Troubleshooting * **`VERCEL_OIDC_TOKEN` not set** — OIDC isn't enabled on the project, or you're running locally (`vercel dev`). For local dev, set `KNOXCALL_CLIENT_ID` + `KNOXCALL_CLIENT_SECRET` directly. * **`invalid_grant: no binding matched`** — preview deployments carry `environment: "preview"`; ensure your bindings differentiate. # Audit Logs overview Source: https://docs.knoxcall.com/essentials/audit-logs/overview Tamper-evident hash-chained audit trail of every API call, secret access, config change, and admin action. Searchable, retainable, exportable. # Audit Logs overview KnoxCall logs every meaningful event in your tenant — secret reads, route changes, key rotations, vault tokenize / detokenize calls, webhook deliveries, admin sign-ins, policy edits — into a single **audit\_logs** table per tenant. The log is **hash-chained**: each row stores a SHA-256 hash of its own contents plus the hash of the previous row. Tampering with any row breaks the chain at that point and every row after it. Auditors can replay the chain offline and detect a single byte-level change. This is what compliance frameworks like SOC 2 Type II and HIPAA call a *non-repudiable audit trail* — a log that's expensive to alter without being noticed. ## Why use it | Problem | Without KnoxCall | With KnoxCall | | - | - | - | | "Who decrypted this PII record?" | Per-app logs, partial coverage, easy to lose | Centralised; every detokenize tagged with user / API key | | "Did anyone modify production after the freeze?" | Hard to prove | Hash-chain verifies untampered; configuration changes appear with old/new values | | Compliance audit asks for 12 months of access logs | Multi-app collection + format normalisation | Export the table; retention configurable per plan | | A leak investigation needs a precise timeline | Reconstruct from app logs | Single ordered timeline per tenant, sub-second timestamps | ## What gets logged Every event in the audit log carries: * `tenant_id` — your tenant * `user_id` — the person who acted: a dashboard user, or the person signed in behind a user-bound token (the `knoxcall` CLI, device and authorization-code apps). Null for actions by an API key or a machine OAuth client * `action` — verb-noun string (e.g. `secret.read`, `route.update`, `vault.detokenize`, `crypto.rotate`) * `resource_type` + `resource_id` — what was acted on * `details` — JSON payload with action-specific context (old/new values for updates, key version for crypto, etc.) * `ip_address` — source IP from the request * `created_at` — sub-second-precision timestamp * `row_hash` — SHA-256 of this row's contents * `prev_hash` — `row_hash` of the previous row in this tenant's chain An action taken through the Management API (`/v1`) also records which credential made the request, in `details.actor_credential` — `{ "kind": "api_key" | "oauth_client", "id": "" }`. When `user_id` names the person behind a user-bound token, `details.actor_user_source` is `"v1_bearer"`, which tells a CLI or app action apart from the same person at the dashboard. KnoxCall sets both fields from the authenticated request; a value in a request body cannot change them. Categories of event that land here: * **Secrets** — create, read, update, delete, rotate. Every read is logged with the route or caller that triggered it. * **Routes** — create, update, delete, environment changes, mTLS cert binding. * **Crypto Keys** — create, rotate, encrypt, decrypt, sign, verify, JWT issue / verify, version destroy. * **Vaults** — create, tokenize, detokenize, rotate, delete (cryptographic erasure). * **Webhooks** — outbound delivery, inbound verification (verified or rejected with reason). * **Admin** — sign-in, sign-out, MFA changes, role assignments, policy edits, API key creation / revocation. * **Tenant config** — billing changes, integration adds / removes, BYOK rotation. The proxy's per-request audit goes into a separate table — `api_requests` — because the volume is much higher and the schema differs (status code, latency, response size). Audit logs cover *changes* and *sensitive reads*; api\_requests covers *every* request. ## Hash-chain integrity Every insert into `audit_logs` runs a BEFORE INSERT trigger that: 1. Reads the most recent `row_hash` for this tenant — that's the previous chain link. 2. Builds a deterministic content string from the new row's columns + the previous hash. 3. Sets the new row's `row_hash` to the SHA-256 of that content. 4. Sets the new row's `prev_hash` to the previous `row_hash`. Verifying the chain after the fact: ```sql theme={"dark"} -- For each row: recompute the content hash and check it matches row_hash. -- Then check that this row's prev_hash equals the prior row's row_hash. SELECT id, created_at, action, (encode(sha256((COALESCE(prev_hash,'') || COALESCE(tenant_id::text,'') || COALESCE(user_id::text,'') || COALESCE(action,'') || COALESCE(resource_type,'') || COALESCE(resource_id::text,'') || COALESCE(details::text,'') || COALESCE(ip_address,''))::bytea), 'hex') = row_hash) AS row_hash_ok FROM audit_logs WHERE tenant_id = $1 ORDER BY id; ``` A single altered byte in any column produces a different SHA-256, which breaks the row's own hash AND every subsequent row's `prev_hash` link. The "first" altered row is provable. The trigger lives at the database level so application code can't accidentally bypass it. Inserts go through one path; the chain is mandatory. ## Retention | Plan | Retention | | - | - | | Free | 7 days | | Starter | 7 days | | Pro | 30 days | | Enterprise | 90 days (extensible per contract) | Older rows are pruned by a daily cron. The hash chain stays intact across pruning — pruning removes the oldest rows, never the middle, so verification of remaining rows continues to work. If you need longer retention for compliance, an Enterprise SOW can extend it to 12 months or 7 years (HIPAA / SOC 2 typical asks). The data lives in your tenant's row partition and you can export it to long-term storage at any time. ## Critical audit chain For BYOK KMS operations, secret migration events, and tenant impersonation, KnoxCall also writes to a **separate append-only table** (`audit_log_critical`) with stronger guarantees than the standard `audit_logs` table: * **Application-layer SHA-256 chain** — KnoxCall code (not a Postgres trigger) computes each row's hash over a length-prefixed canonical byte sequence. An external verifier can reproduce every hash from raw DB values without any KnoxCall involvement. * **No pruning** — the critical chain is never deleted, regardless of plan retention. * **Serialized writes** — an advisory lock prevents concurrent inserts from forking the sequence. This table is used for the 14 actions across BYOK KMS, migration lifecycle, migration item review, migration DB proxy routes, and tenant impersonation. See the [Audit Logs API reference](/api-reference/audit-logs#sha-256-hash-chain--critical-audit-chain) for the full action list, row schema, and verification endpoint. ## Filtering and search The Audit Logs page in the admin UI supports: * **Time window** — last hour / 24h / 7 days / custom. * **Action prefix** — `secret.*`, `vault.*`, `crypto.*`, etc. for fast triage. * **Resource** — type + name search. * **User / API key** — who triggered the action. * **Free-text search** over the JSON details payload. Each row links to a detail page that shows the full JSON `details`, the source IP geolocation (when available), and — for resource events — the resource as it exists today plus a link to its own audit history. ## Export to SIEM Two options: 1. **Webhook delivery** — every audit row fires an outbound webhook to your endpoint of choice. Signed with HMAC-SHA256 in any of the supported [webhook formats](/essentials/webhook-signing/overview), so your SIEM can verify authenticity end-to-end. 2. **Bulk export** — use the **Export** button in the admin UI (Logs → Audit) to download an ndjson file of the current filter set, suitable for ingestion into Splunk, Datadog, ELK, or similar. Both methods preserve the `row_hash` / `prev_hash` columns so external systems can re-verify the chain after import. ## Quick start (UI) 1. Go to **Logs → Audit** in the admin UI. 2. Filter by action (e.g. `secret.read`) and resource type. 3. Click any row for the full detail view. 4. Use the **Export** button (top right) for an ndjson dump of the current filter set. ## Quick start (API) ```bash theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) # Audit logs for a specific action (exact match) curl -H "Authorization: Bearer $TOKEN" \ "https://api.knoxcall.com/v1/audit-logs?action=secret.read&per_page=50" # Filter by resource type curl -H "Authorization: Bearer $TOKEN" \ "https://api.knoxcall.com/v1/audit-logs?resource_type=secret" ``` Supported filter parameters: `action` (exact action string match), `resource_type`, `page`, `per_page`. ## Next steps * [Crypto Keys overview →](/essentials/crypto-keys/crypto-keys-overview) — every encrypt / decrypt is audited * [Inbound Webhooks →](/essentials/inbound-webhooks/overview) — verification results land in the audit log * [Vaults →](/essentials/vaults/overview) — tokenize / detokenize calls audited per-token # CIDR Notation Explained Source: https://docs.knoxcall.com/essentials/clients/cidr-notation Understand CIDR notation for IP ranges. Learn how to calculate subnets, authorize networks, and use CIDR in KnoxCall. # CIDR Notation Explained Learn how to use CIDR (Classless Inter-Domain Routing) notation to authorize ranges of IP addresses in KnoxCall. *** ## What is CIDR? **CIDR** (pronounced "cider") is a way to represent a **range of IP addresses** using a single notation. ### Format ```text theme={"dark"} IP_ADDRESS/PREFIX_LENGTH ``` **Example:** ```text theme={"dark"} 192.168.1.0/24 ``` **Parts:** * `192.168.1.0` - Network address (base IP) * `/24` - Prefix length (how many IPs in range) *** ## Why Use CIDR? ### The Problem Without CIDR Imagine authorizing an office with 200 employees: ```text theme={"dark"} ❌ Without CIDR (tedious): 192.168.1.1 192.168.1.2 192.168.1.3 ... 192.168.1.200 (Need 200 separate client entries!) ``` ### The Solution With CIDR ```text theme={"dark"} ✅ With CIDR (one entry): 192.168.1.0/24 (Covers 192.168.1.1 to 192.168.1.254 - all 254 usable IPs!) ``` *** ## How CIDR Works ### The Prefix Number The number after the `/` tells you how many IPs are in the range. **Formula:** ```text theme={"dark"} Number of IPs = 2^(32 - prefix) ``` **Examples:** ```text theme={"dark"} /32 → 2^(32-32) = 2^0 = 1 IP /24 → 2^(32-24) = 2^8 = 256 IPs /16 → 2^(32-16) = 2^16 = 65,536 IPs /8 → 2^(32-8) = 2^24 = 16,777,216 IPs ``` ### Common CIDR Ranges | CIDR | IPs | Common Use | Example | | - | - | - | - | | `/32` | 1 | Single host | `52.123.45.67/32` | | `/30` | 4 | Point-to-point | `10.0.0.0/30` | | `/28` | 16 | Small subnet | `192.168.1.0/28` | | `/24` | 256 | **Office network** | `192.168.1.0/24` | | `/20` | 4,096 | Large network | `172.31.0.0/20` | | `/16` | 65,536 | **Corporate VPN** | `10.0.0.0/16` | | `/8` | 16M+ | Very large | `10.0.0.0/8` | *** ## /24 - Most Common (256 IPs) ### Example: Office Network ```text theme={"dark"} CIDR: 192.168.1.0/24 ``` **Range covered:** ```text theme={"dark"} First IP: 192.168.1.0 (network address - reserved) Usable: 192.168.1.1 to 192.168.1.254 Last IP: 192.168.1.255 (broadcast - reserved) Total: 256 addresses Usable: 254 addresses (for devices) ``` **What changes:** * Last octet (number): 0 to 255 * Everything else stays the same **Authorizes:** ```text theme={"dark"} ✅ 192.168.1.1 (office computer) ✅ 192.168.1.50 (another computer) ✅ 192.168.1.100 (developer laptop) ✅ 192.168.1.254 (last usable) ❌ 192.168.2.1 (different subnet!) ❌ 192.168.0.255 (different subnet!) ``` *** ## /16 - Large Networks (65,536 IPs) ### Example: Corporate VPN ```text theme={"dark"} CIDR: 10.8.0.0/16 ``` **Range covered:** ```text theme={"dark"} First IP: 10.8.0.0 Usable: 10.8.0.1 to 10.8.255.254 Last IP: 10.8.255.255 Total: 65,536 addresses ``` **What changes:** * Last TWO octets: 0.0 to 255.255 * First two stay the same: 10.8 **Authorizes:** ```text theme={"dark"} ✅ 10.8.0.1 (VPN user 1) ✅ 10.8.0.50 (VPN user 50) ✅ 10.8.100.200 (VPN user on different subnet) ✅ 10.8.255.254 (last usable) ❌ 10.7.0.1 (different network) ❌ 10.9.0.1 (different network) ``` *** ## /20 - Cloud Subnets (4,096 IPs) ### Example: AWS VPC Subnet ```text theme={"dark"} CIDR: 172.31.0.0/20 ``` **Range covered:** ```text theme={"dark"} First IP: 172.31.0.0 Usable: 172.31.0.1 to 172.31.15.254 Last IP: 172.31.15.255 Total: 4,096 addresses ``` **What changes:** * Last octet: Full range (0-255) * Third octet: Partial range (0-15) **Authorizes:** ```text theme={"dark"} ✅ 172.31.0.1 (EC2 instance) ✅ 172.31.5.100 (another instance) ✅ 172.31.15.254 (last usable) ❌ 172.31.16.1 (outside range - /20 only goes to .15) ❌ 172.30.0.1 (different network) ``` *** ## /32 - Single IP (Don't Use CIDR) ### Example: Single Server ```text theme={"dark"} CIDR: 52.123.45.67/32 ``` **Range covered:** ```text theme={"dark"} Just: 52.123.45.67 Total: 1 address (only this specific IP) ``` **Note:** In KnoxCall, just enter `52.123.45.67` without `/32`. It's implied. *** ## Calculating CIDR Ranges ### Quick Method **Step 1: Look at the prefix** ```text theme={"dark"} /24 → Last octet varies (256 IPs) /16 → Last 2 octets vary (65,536 IPs) /8 → Last 3 octets vary (16M IPs) ``` **Step 2: Calculate range** ```text theme={"dark"} /24 example: 192.168.1.0/24 - Base: 192.168.1.0 - Range: 192.168.1.0 to 192.168.1.255 - Usable: 192.168.1.1 to 192.168.1.254 ``` ### CIDR Calculator Tools **Online calculators:** * [https://www.ipaddressguide.com/cidr](https://www.ipaddressguide.com/cidr) * [https://cidr.xyz/](https://cidr.xyz/) * [https://www.subnet-calculator.com/](https://www.subnet-calculator.com/) **Example:** ```text theme={"dark"} Input: 192.168.1.0/24 Output: First: 192.168.1.0 Last: 192.168.1.255 Usable: 192.168.1.1 - 192.168.1.254 ``` *** ## Common Scenarios ### Scenario 1: Office Wi-Fi **Need:** Authorize all 50 office computers **Solution:** ```text theme={"dark"} CIDR: 192.168.1.0/24 ``` **Why /24:** * Office has 50 devices now * Room to grow to 254 devices * Typical office network size **Client in KnoxCall:** ```text theme={"dark"} Name: office-wifi-main Type: Server IP: 192.168.1.0/24 Description: Main office Wi-Fi network ``` ### Scenario 2: Corporate VPN **Need:** Authorize 500 remote employees **Solution:** ```text theme={"dark"} CIDR: 10.8.0.0/16 ``` **Why /16:** * VPN has 500 users now * Room for 65,000+ connections * Standard VPN range **Client in KnoxCall:** ```text theme={"dark"} Name: company-vpn Type: Server IP: 10.8.0.0/16 Description: Corporate VPN - all remote workers ``` ### Scenario 3: AWS Subnet **Need:** Authorize EC2 instances in subnet **Solution:** ```text theme={"dark"} CIDR: 172.31.0.0/20 ``` **Why /20:** * AWS VPC subnet * \~4,000 IPs * Multiple microservices **Client in KnoxCall:** ```text theme={"dark"} Name: aws-prod-subnet Type: Server IP: 172.31.0.0/20 Description: AWS VPC subnet - production instances ``` ### Scenario 4: Partner Network **Need:** Authorize partner company's entire network **Solution:** ```text theme={"dark"} CIDR: 203.14.0.0/24 ``` **Why /24:** * Partner has small office * \~100 employees * /24 covers them **Client in KnoxCall:** ```text theme={"dark"} Name: partner-acme-network Type: Server IP: 203.14.0.0/24 Description: ACME Corp office network ``` *** ## Testing CIDR Ranges ### How to Test **Step 1: Create client with CIDR** ```text theme={"dark"} IP: 192.168.1.0/24 ``` **Step 2: Test from different IPs** **From 192.168.1.50:** ```bash theme={"dark"} curl https://a1b2c3d4.acme.knoxcall.com/api/test \ -H "x-knoxcall-route: test-route" ✅ Success: 200 OK (IP in range!) ``` **From 192.168.2.50:** ```bash theme={"dark"} curl https://a1b2c3d4.acme.knoxcall.com/api/test \ -H "x-knoxcall-route: test-route" ❌ Error: 403 Forbidden (IP not in range) ``` *** ## Private IP Ranges ### Reserved Private Networks These IP ranges are for **internal networks only** (not internet-routable): **Class A (Large):** ```text theme={"dark"} 10.0.0.0/8 - Range: 10.0.0.0 to 10.255.255.255 - IPs: 16,777,216 - Use: Large corporate networks, VPNs ``` **Class B (Medium):** ```text theme={"dark"} 172.16.0.0/12 - Range: 172.16.0.0 to 172.31.255.255 - IPs: 1,048,576 - Use: Medium networks, cloud providers (AWS uses 172.31.0.0/16) ``` **Class C (Small):** ```text theme={"dark"} 192.168.0.0/16 - Range: 192.168.0.0 to 192.168.255.255 - IPs: 65,536 - Use: Home/office networks (most common: 192.168.1.0/24) ``` **Note:** Private IPs only work for: * Office networks (devices behind router) * VPNs (tunnel private network over internet) * Cloud private subnets **Don't use for:** * Public internet servers (use public IPs) *** ## Public vs Private IPs ### Public IPs (Internet) ```text theme={"dark"} Examples: 52.123.45.67 (AWS Elastic IP) 104.21.45.67 (Cloudflare) 142.250.185.78 (Google) Format: Any IP not in private ranges CIDR: Typically /32 (single IP) or /24 (small block) ``` **Use in KnoxCall:** * Production servers * Partner webhooks * Public APIs ### Private IPs (Internal Networks) ```text theme={"dark"} Examples: 192.168.1.0/24 (Office Wi-Fi) 10.8.0.0/16 (Corporate VPN) 172.31.0.0/20 (AWS VPC private subnet) Format: 10.x.x.x, 172.16-31.x.x, 192.168.x.x CIDR: Often /16 or /24 ``` **Use in KnoxCall:** * Office networks * VPN access * Internal testing *** ## Common Mistakes ### ❌ Mistake 1: Using /32 for Networks ```text theme={"dark"} ❌ Wrong: IP: 192.168.1.1/32 Type: Server Problem: /32 = only 1 IP (defeats purpose) ``` **Fix:** ```text theme={"dark"} ✅ Correct: IP: 192.168.1.0/24 Type: Server ``` ### ❌ Mistake 2: Too Broad Range ```text theme={"dark"} ❌ Dangerous: IP: 0.0.0.0/0 Problem: Allows ENTIRE internet! ``` **Fix:** ```text theme={"dark"} ✅ Correct: IP: 192.168.1.0/24 (specific network only) ``` ### ❌ Mistake 3: Wrong Base IP ```text theme={"dark"} ❌ Wrong: IP: 192.168.1.55/24 Problem: Base should be .0 not .55 ``` **Fix:** ```text theme={"dark"} ✅ Correct: IP: 192.168.1.0/24 ``` ### ❌ Mistake 4: Prefix Doesn't Match Network ```text theme={"dark"} ❌ Wrong: Need: 50 IPs Used: 10.0.0.0/8 (16 million IPs!) Problem: Massively oversized ``` **Fix:** ```text theme={"dark"} ✅ Correct: Need: 50 IPs Used: 10.0.0.0/24 (256 IPs - enough headroom) ``` *** ## CIDR Cheat Sheet | Prefix | IPs | Typical Use | | - | - | - | | `/32` | 1 | Single server (don't use CIDR) | | `/30` | 4 | Point-to-point link | | `/29` | 8 | Very small subnet | | `/28` | 16 | Small office | | `/27` | 32 | Small network | | `/26` | 64 | Medium office | | `/25` | 128 | Large office | | `/24` | 256 | **Standard office network** | | `/23` | 512 | Large office/campus | | `/22` | 1,024 | Campus network | | `/21` | 2,048 | Large network | | `/20` | 4,096 | **Cloud subnet (AWS)** | | `/19` | 8,192 | Large cloud network | | `/18` | 16,384 | Very large network | | `/17` | 32,768 | Enterprise | | `/16` | 65,536 | **Corporate VPN, large enterprise** | | `/15` | 131,072 | ISP | | `/14` | 262,144 | Large ISP | | `/13` | 524,288 | Very large ISP | | `/12` | 1M+ | Regional network | | `/11` | 2M+ | National network | | `/10` | 4M+ | Continental network | | `/9` | 8M+ | Very large network | | `/8` | 16M+ | **Class A network** | *** ## Tools & Resources ### CIDR Calculators **IP Address Guide:** [https://www.ipaddressguide.com/cidr](https://www.ipaddressguide.com/cidr) **CIDR.xyz:** [https://cidr.xyz/](https://cidr.xyz/) **Subnet Calculator:** [https://www.subnet-calculator.com/](https://www.subnet-calculator.com/) ### Command Line Tools **Test if IP is in CIDR range:** ```bash theme={"dark"} # Using Python python3 -c " import ipaddress network = ipaddress.ip_network('192.168.1.0/24') ip = ipaddress.ip_address('192.168.1.50') print(ip in network) # True or False " ``` **List all IPs in range:** ```bash theme={"dark"} # Using Python python3 -c " import ipaddress for ip in ipaddress.ip_network('192.168.1.0/24'): print(ip) " ``` *** ## Best Practices ### ✅ Do 1. **Use smallest range needed** ``` ✅ Office has 50 users → /24 (256 IPs) ❌ Office has 50 users → /16 (65,536 IPs) ``` 2. **Use .0 as base** ``` ✅ 192.168.1.0/24 ❌ 192.168.1.1/24 ``` 3. **Document what's in range** ``` Description: "Main office Wi-Fi - floors 1-3, ~100 employees" ``` 4. **Test before production** ``` Test with curl from expected IP range Verify rejection from outside range ``` 5. **Use private IPs for internal** ``` ✅ 192.168.1.0/24 for office ✅ 10.8.0.0/16 for VPN ``` ### ❌ Don't 1. **Don't use 0.0.0.0/0** ``` ❌ Allows entire internet (defeats purpose!) ``` 2. **Don't over-authorize** ``` ❌ Need 10 IPs → /8 (16 million!) ✅ Need 10 IPs → /28 (16 IPs) ``` 3. **Don't forget private vs public** ``` ❌ Using 192.168.1.0/24 for internet servers ✅ Using public IP for internet servers ``` 4. **Don't mix ranges** ``` ❌ One client for unrelated networks ✅ Separate clients for separate networks ``` *** ## Quick Reference | Your Network | Recommended CIDR | IPs | | - | - | - | | **Single server** | `52.123.45.67` (no /32) | 1 | | **Small office** (\< 20) | `192.168.1.0/28` | 16 | | **Medium office** (\< 100) | `192.168.1.0/24` | 256 | | **Large office** (\< 500) | `192.168.0.0/23` | 512 | | **VPN** (\< 1000) | `10.8.0.0/22` | 1,024 | | **Large VPN** (\< 10k) | `10.0.0.0/18` | 16,384 | | **Enterprise VPN** | `10.0.0.0/16` | 65,536 | | **AWS Subnet** | `172.31.0.0/20` | 4,096 | *** ## Next Steps Client basics and IP whitelisting Server, User, and Network types Create and assign clients Troubleshoot IP problems *** **Quick Tip:** For most offices, `/24` (256 IPs) is perfect. For VPNs, `/16` (65,536 IPs) gives plenty of headroom. When in doubt, use a CIDR calculator! # Client Types Explained Source: https://docs.knoxcall.com/essentials/clients/client-types Deep dive into Server and User client types. Learn when to use each type and best practices. # Client Types Explained KnoxCall supports two types of clients, each optimized for different use cases. Both types support single IPs and CIDR ranges. This guide helps you choose the right type. *** ## The Two Types | Type | Best For | IP Stability | Example | | - | - | - | - | | **Server** | Production servers, office networks, cloud subnets | Static (unchanging) | AWS EC2 with Elastic IP, office `192.168.1.0/24` | | **User** | Developer machines, QA testers | Dynamic (may change) | Laptop on home Wi-Fi | *** ## Server Type ### Overview **Server** clients represent production infrastructure with **static IP addresses** that never (or rarely) change. ### Characteristics ✅ **Static IP address** - IP doesn't change ✅ **Production-ready** - Designed for reliability ✅ **Single IP** - One specific address ✅ **High trust** - Critical infrastructure ### When to Use Use **Server** type when: * Production API servers * Staging servers * Database servers * Backend services with static IPs * Cloud instances with Elastic IPs * VPS with dedicated IPs * Partner webhook servers ### Examples #### AWS EC2 with Elastic IP ```text theme={"dark"} Name: prod-api-server-1 Type: Server IP: 52.123.45.67 Description: Main production API server in AWS us-east-1 ``` **Why Server type:** * Elastic IP is static (never changes unless you release it) * Production traffic * Critical infrastructure #### DigitalOcean Droplet ```text theme={"dark"} Name: staging-backend Type: Server IP: 159.65.123.45 Description: Staging environment backend on DigitalOcean ``` **Why Server type:** * Droplet has static IP * Staging environment (important but not production) #### Partner Integration ```text theme={"dark"} Name: partner-acme-webhook Type: Server IP: 100.25.30.45 Description: ACME Corp webhook callback server ``` **Why Server type:** * External partner's server * Fixed IP address * Business-critical integration ### Best Practices ✅ **Do:** * Always use for production * Document server purpose in description * Use meaningful names (`prod-api-1` not `server1`) * Track in inventory (spreadsheet/wiki) * Regular security audits ❌ **Don't:** * Use for developer laptops (use User type) * Use for dynamic IPs (IP will change, causing issues) * Use for IP ranges without also locking down the server identity *** ## User Type ### Overview **User** clients represent individual people or devices with **potentially dynamic IP addresses**. ### Characteristics ✅ **Dynamic IP** - May change occasionally ✅ **Individual person/device** - Not infrastructure ✅ **Development-focused** - Testing, debugging ✅ **Lower trust** - Temporary access ### When to Use Use **User** type when: * Developer laptops * Home office machines * QA team computers * Temporary contractor access * Mobile devices (rare) * Any IP that might change ### Examples #### Developer Laptop ```text theme={"dark"} Name: john-dev-macbook Type: User IP: 203.45.67.89 Description: John's MacBook Pro for local development ``` **Why User type:** * Home Wi-Fi IP changes occasionally * Individual developer * Development/testing only #### QA Team Member ```text theme={"dark"} Name: qa-alice-laptop Type: User IP: 180.123.45.67 Description: Alice (QA) - testing environment access ``` **Why User type:** * Office IP might change * Individual tester * Non-production use #### Remote Contractor ```text theme={"dark"} Name: contractor-bob-temp Type: User IP: 192.5.8.100 Description: Bob (contractor) - temporary access for Q1 2025 ``` **Why User type:** * Temporary access * Individual person * Dynamic IP likely ### Dynamic IP Challenges **Problem:** Home/office internet usually has dynamic IPs that change. **When IPs change:** * Router restarts * ISP lease expires (every 24-72 hours typically) * VPN connects/disconnects * Moving locations **Solutions:** **Option 1: Update IP when it changes** ```bash theme={"dark"} # Check current IP curl https://api.ipify.org # Update client in KnoxCall UI # Resources → Clients → Edit → Update IP ``` **Option 2: Use API key authentication** * Create API key with "Allow any IP" * Use key in requests instead * No IP whitelisting needed **Option 3: Use VPN with static IP** * Connect to VPN with static egress IP * Whitelist VPN IP * Always connect through VPN ### Best Practices ✅ **Do:** * Include person's name in client name * Update description with contact info * Remove when person leaves company * Use "Allow any IP" API keys for dev (easier) * Assign only to dev/staging environments ❌ **Don't:** * Use for production servers * Give production access to User clients * Forget to remove when contractor leaves * Use generic names like `user1` *** ## Using CIDR Ranges Both Server and User types support **CIDR notation** for authorizing IP ranges. This is useful for office networks, VPNs, and cloud subnets. ### Examples with CIDR #### Office Network ```text theme={"dark"} Name: office-wifi-main Type: Server IP: 192.168.1.0/24 Description: Main office Wi-Fi - all floors, ~200 employees ``` Covers 256 addresses: `192.168.1.0` to `192.168.1.255` #### Corporate VPN ```text theme={"dark"} Name: company-vpn Type: Server IP: 10.8.0.0/16 Description: Corporate VPN - all remote employees ``` Covers 65,536 addresses: `10.8.0.0` to `10.8.255.255` #### Cloud Provider Subnet ```text theme={"dark"} Name: aws-us-east-subnet Type: Server IP: 172.31.0.0/20 Description: AWS VPC subnet for microservices ``` Covers 4,096 addresses: `172.31.0.0` to `172.31.15.255` ### Common CIDR Ranges * `/32` - 1 IP (just enter the IP without `/32`) * `/24` - 256 IPs (e.g., `192.168.1.0/24`) * `/16` - 65,536 IPs (e.g., `10.0.0.0/16`) * `/8` - 16,777,216 IPs (very large, rare) See [CIDR Notation Guide](/essentials/clients/cidr-notation) for details. ### CIDR Best Practices ✅ **Do:** * Use smallest range that covers your needs * Document what network the range represents * Audit periodically (remove if unused) ❌ **Don't:** * Use `0.0.0.0/0` (allows entire internet!) * Use overly broad ranges unnecessarily *** ## Comparison Table | Feature | Server | User | | - | - | - | | **IP Format** | Single IP or CIDR range | Single IP or CIDR range | | **Example IP** | `52.123.45.67` or `192.168.1.0/24` | `203.45.67.89` | | **Stability** | Permanent | May change | | **Use Case** | Production servers, office networks | Developer laptops, QA testers | | **Trust Level** | High | Medium | | **Environment** | Production, Staging | Dev/Staging | | **Maintenance** | Low | Medium (IP updates) | *** ## Choosing the Right Type ### Decision Tree ```text theme={"dark"} Is this infrastructure (servers, networks, VPNs)? ├─ YES → Use Server type └─ NO → Is this a person (developer, tester)? └─ YES → Use User type ``` ### Examples **Scenario: AWS EC2 instance** * Infrastructure? ✅ Yes (static Elastic IP) * **→ Server type** **Scenario: Developer's laptop** * Infrastructure? ❌ No (person's device) * **→ User type** **Scenario: Office with 50 employees** * Infrastructure? ✅ Yes (network) * **→ Server type** with CIDR range (e.g., `192.168.1.0/24`) **Scenario: VPN with 100 users** * Infrastructure? ✅ Yes (VPN network) * **→ Server type** with CIDR range (e.g., `10.8.0.0/16`) *** ## Security Considerations ### Trust Levels **Server (Highest Trust):** * ✅ Production traffic allowed * ✅ Access to sensitive data * ✅ Critical operations * ⚠️ CIDR ranges increase exposure — use the smallest range needed **User (Medium Trust):** * ⚠️ Development/staging only * ⚠️ Limited permissions * ⚠️ Regular audits ### Recommendations by Environment **Production:** * ✅ Server type only * ❌ No User types * ⚠️ CIDR ranges only for trusted corporate networks **Staging:** * ✅ Server type for staging servers * ✅ User type for QA team **Development:** * ✅ Both types acceptable * ✅ User type most common * ✅ API keys easier than IP whitelisting *** ## Migration Between Types ### User → Server (When Getting Static IP) **Before:** ```text theme={"dark"} Name: dev-server Type: User IP: 203.45.67.89 (dynamic) ``` **After getting Elastic IP:** ```text theme={"dark"} Name: prod-api-server Type: Server IP: 52.123.45.67 (Elastic IP - static) ``` **Steps:** 1. Provision Elastic IP from cloud provider 2. Assign to instance 3. Edit client in KnoxCall 4. Change type to Server 5. Update IP to Elastic IP 6. Update description 7. Save ### Network → Multiple Servers **Before (inefficient):** ```text theme={"dark"} Name: cloud-subnet Type: Server IP: 172.31.0.0/20 Description: All instances (10 production, 50 dev) ``` **After (more secure):** ```text theme={"dark"} Production servers (Server type): - prod-api-1: 172.31.1.10 - prod-api-2: 172.31.1.11 - prod-db-1: 172.31.1.20 Development network (Server type with CIDR): - dev-subnet: 172.31.8.0/22 ``` **Benefit:** Production servers explicitly listed, dev instances in subnet. *** ## Real-World Patterns ### Pattern 1: Startup (Small Team) ```text theme={"dark"} Production: - Server: prod-api (52.123.45.67) - Server: prod-db (52.123.45.68) Development: - User: john-laptop (dynamic) - User: jane-laptop (dynamic) - User: bob-laptop (dynamic) ``` **Why:** Simple setup, few people, API keys easier than IP management for devs. ### Pattern 2: Enterprise (Large Team) ```text theme={"dark"} Production: - Server: prod-api-1 through prod-api-10 (specific IPs) - Server: prod-worker-1 through prod-worker-5 Staging: - Server: staging-subnet (10.1.0.0/24) - Server: office-network (192.168.0.0/16) Development: - Server: dev-vpn (10.8.0.0/16) ``` **Why:** Granular production control, convenient staging/dev access. ### Pattern 3: Agency/Consultancy ```text theme={"dark"} Clients: - Server: client-acme-network (203.14.0.0/24) - Server: client-widgets-vpn (10.9.0.0/16) - Server: client-globex-server (100.25.30.45) Internal: - Server: office-wifi (192.168.1.0/24) - User: contractor-alice (temporary) - User: contractor-bob (temporary) ``` **Why:** Each client gets appropriate access, contractors temporary User type. *** ## Quick Reference | Situation | Type | Example | | - | - | - | | Production server with Elastic IP | Server | `52.123.45.67` | | Developer's laptop at home | User | `203.45.67.89` | | Office Wi-Fi network | Server | `192.168.1.0/24` | | Staging server on cloud | Server | `54.200.11.22` | | QA team member | User | `180.123.45.67` | | Corporate VPN | Server | `10.8.0.0/16` | | Partner's webhook server | Server | `100.25.30.45` | | Temporary contractor | User | `192.5.8.100` | *** ## Next Steps Client basics and concepts Create, edit, assign clients Understanding IP ranges (coming soon) Troubleshoot IP problems *** **Pro Tip:** For production, always use **Server** type with static IPs. For development, use **User** type or API keys with "Allow any IP" to avoid constant IP updates. # Managing Clients Source: https://docs.knoxcall.com/essentials/clients/managing-clients Learn how to create, edit, delete, and assign clients in KnoxCall. Complete guide to client lifecycle management. # Managing Clients Complete guide to creating, editing, deleting, and assigning clients for IP authorization. *** ## Creating Clients ### Step 1: Navigate to Clients 1. Click **Resources** in the sidebar 2. Select **Clients** 3. Click **Create Client** button ### Step 2: Fill in Client Details #### Name (Required) Give your client a descriptive name. **Good examples:** ```text theme={"dark"} production-api-server-1 staging-server-aws john-dev-laptop office-network-main partner-acme-webhook-server ``` **Bad examples:** ```text theme={"dark"} client1 (not descriptive) server (too generic) test (what is this?) ``` #### Type (Required) Choose the appropriate type: | Type | Best For | Example | | - | - | - | | **Server** | Production servers, office networks, cloud subnets | AWS EC2 with Elastic IP, `192.168.1.0/24` | | **User** | Developer machines, dynamic IPs | Laptops, home offices | **Recommendation:** Use **Server** for production infrastructure, **User** for individual people. Both types support single IPs and CIDR ranges. #### IP Address (Required) Enter the IP address or CIDR range. **Single IP:** ```text theme={"dark"} 52.123.45.67 ``` **IP Range (CIDR):** ```text theme={"dark"} 192.168.1.0/24 (256 IPs: .0 to .255) 10.0.0.0/16 (65,536 IPs) 203.14.0.0/24 (256 IPs for office) ``` **How to find your IP:** ```bash theme={"dark"} curl https://api.ipify.org ``` #### Description (Optional but Recommended) Document what this client is for. **Good descriptions:** ```text theme={"dark"} "Main production API server in AWS us-east-1" "John's development MacBook Pro" "Office network - all floors" "Partner XYZ webhook callback server" "Staging environment in GCP" ``` #### Enabled Toggle * **ON** (default): Client is active and authorized * **OFF**: Client is disabled, requests will be rejected ### Step 3: Save Click **Create Client** ✅ Client is now created but **not yet assigned** to any routes! *** ## Assigning Clients to Routes Creating a client doesn't automatically authorize it. You must **assign it to specific routes**. ### Method 1: Assign from Route Page **Best for:** Assigning to one route 1. Go to **Routes** 2. Click on your route 3. Go to **Clients** tab 4. Select environment (e.g., "production") 5. Click **Assign Clients** 6. Check the boxes next to clients you want to authorize 7. Click **Save** ✅ Those clients can now access this route in that environment! ### Method 2: Assign from Client Page **Best for:** Assigning one client to multiple routes 1. Go to **Resources** → **Clients** 2. Click on your client 3. Go to **Assigned Routes** tab 4. Click **Assign to Routes** 5. Select routes and environments 6. Click **Save** *** ## Per-Environment Assignment Clients can be assigned **differently per environment**. ### Example Setup **Route:** `user-api` **Production environment:** * Clients: `prod-server-1`, `prod-server-2` **Staging environment:** * Clients: `staging-server`, `qa-laptop-1`, `qa-laptop-2` **Development environment:** * Clients: `dev-laptop-1`, `dev-laptop-2`, `dev-laptop-3` ### How to Assign Per Environment 1. Go to route → **Clients** tab 2. **Select environment** dropdown (e.g., "production") 3. Click **Assign Clients** 4. Check clients for **this environment only** 5. Save 6. **Repeat for each environment** **Result:** * Production requests only work from production servers * Staging requests work from staging + QA machines * Dev requests work from developer laptops *** ## Platform-Managed Clients Two clients in your list are created by KnoxCall itself and cannot be presented by anyone else: | Client | What it identifies | Assign it when | | - | - | - | | **KnoxCall Dashboard** | The dashboard's **Call Route** test button | You want to test a route from the dashboard on an environment that requires authorized clients | | **KnoxCall Workflows** | A workflow **HTTP Request** node in **Route** mode calling one of your routes | You want workflows to call routes on an environment that requires authorized clients | Each one proves its identity with a signed assertion that is bound to your workspace, the route, the environment and the Live/Test space — it cannot be replayed from outside KnoxCall or by another workspace. They grant nothing until you assign them, exactly like any other client, and removing the assignment revokes the access. Deleting one simply recreates it, unassigned, the next time it is needed. The assignment is honoured by both kinds of gate: an environment that **requires authorized clients**, and a route that has an **IP allowlist** (the platform's own address is never on the list, so the assignment is what lets its call through). Do not allowlist KnoxCall's own outbound IP address as a Server client or in a route's IP allowlist. That address is shared by every workspace's traffic, so it cannot identify yours; the platform refuses it, and the platform-managed clients above are the supported way to let KnoxCall call your routes. *** ## Editing Clients ### When to Edit Common reasons to edit: * IP address changed (especially for User type) * Need to update description * Need to enable/disable temporarily * Expanding IP range (CIDR) ### How to Edit 1. Go to **Resources** → **Clients** 2. Find your client in the list 3. Click on the client name 4. Click **Edit** button 5. Update fields as needed 6. Click **Save** ### Common Edits #### Update IP Address **When:** Your development machine IP changed **Before:** ```text theme={"dark"} IP: 203.45.67.89 ``` **After:** ```text theme={"dark"} IP: 203.45.67.100 ``` **How to check current IP:** ```bash theme={"dark"} curl https://api.ipify.org ``` #### Expand IP Range **When:** Need to cover more IPs **Before:** ```text theme={"dark"} IP: 192.168.1.0/24 (covers .0 to .255) ``` **After:** ```text theme={"dark"} IP: 192.168.0.0/16 (covers .0.0 to .255.255) ``` #### Change Type **When:** Server gets static IP **Before:** ```text theme={"dark"} Type: User IP: 203.45.67.89 (dynamic) ``` **After:** ```text theme={"dark"} Type: Server IP: 52.123.45.67 (Elastic IP - static) ``` *** ## Enabling/Disabling Clients ### Temporarily Disable **When to use:** * Testing without deleting * Temporarily block access * Troubleshooting **How:** 1. Edit client 2. Toggle **Enabled** to **OFF** 3. Save **Result:** Client exists but requests are rejected. ### Re-Enable **How:** 1. Edit client 2. Toggle **Enabled** to **ON** 3. Save **Result:** Client is active again. *** ## Deleting Clients ### When to Delete * Client is no longer needed * IP is permanently decommissioned * Cleaning up old test clients * Security: remove unauthorized access ### ⚠️ Warning Deleting a client is **permanent** and will: * Remove client from ALL routes * Reject all future requests from this IP * Cannot be undone **Make sure you want to delete!** ### How to Delete 1. Go to **Resources** → **Clients** 2. Find your client 3. Click on client name 4. Click **Delete** button 5. Confirm deletion ✅ Client is permanently removed. ### Safe Alternative: Disable Instead Instead of deleting, consider **disabling**: * Keeps configuration * Can be re-enabled later * Reversible *** ## Viewing Client Information ### Client Details Page Click on any client to see: **Basic Info:** * Name, Type, IP address * Description * Enabled status * Created date **Assigned Routes:** * Which routes this client can access * Which environments it's assigned to **Activity Log:** * Recent requests from this IP * Success/failure rate * Last seen timestamp *** ## Bulk Operations ### Assign Multiple Clients at Once **Scenario:** Add 5 developer laptops to dev environment **Steps:** 1. Go to route → **Clients** tab 2. Select "development" 3. Click **Assign Clients** 4. Check all 5 developer clients at once 5. Save ✅ All 5 clients assigned in one operation! ### Create Multiple Clients from CSV **Not currently supported in UI** - Need to create one by one. **Workaround for many clients:** Use the KnoxCall API to script client creation: ```bash theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) for ip in $(cat ip_list.txt); do curl -X POST https://api.knoxcall.com/v1/clients \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "{\"name\": \"server-$ip\", \"ip_address\": \"$ip\", \"type\": \"server\"}" done ``` *** ## Client Organization ### Naming Convention Use consistent naming for easy management: **Pattern:** `{environment}-{purpose}-{identifier}` **Examples:** ```text theme={"dark"} prod-api-server-1 prod-api-server-2 staging-app-server dev-john-laptop dev-jane-laptop office-network-floor-1 partner-stripe-webhook ``` ### Use Descriptions Always add descriptions to remember purpose: ```text theme={"dark"} Name: prod-api-server-1 Description: Main production API server in AWS us-east-1, Elastic IP 52.123.45.67 Name: dev-john-laptop Description: John's MacBook Pro for local development, IP changes frequently Name: office-network Description: Main office Wi-Fi network covering all floors, 192.168.1.0/24 ``` *** ## Common Scenarios ### Scenario 1: New Developer Joins **Steps:** 1. Ask developer for their IP: `curl https://api.ipify.org` 2. Create client: * Name: `dev-alice-laptop` * Type: User * IP: `203.50.60.70` 3. Assign to development environment of all necessary routes 4. Developer can now test locally! ### Scenario 2: Move to Production **Steps:** 1. Provision production server with **static IP** (Elastic IP) 2. Create client: * Name: `prod-api-server-1` * Type: Server * IP: `52.100.200.50` (Elastic IP) 3. Assign to **production environment only** (not staging/dev) 4. Test carefully before going live ### Scenario 3: Office Network **Steps:** 1. Get office network CIDR range: `192.168.1.0/24` 2. Create client: * Name: `office-network-main` * Type: Server * IP: `192.168.1.0/24` 3. Assign to staging or dev environments (not production!) 4. All office computers can now access ### Scenario 4: IP Changed Unexpectedly **Symptoms:** Requests suddenly fail with "IP not authorized" **Steps:** 1. Check current IP: `curl https://api.ipify.org` 2. Compare to client IP in KnoxCall 3. If different → Edit client 4. Update IP to new value 5. Save → Requests work again! **Prevention for dev:** Use token auth instead of IP whitelisting (see below). *** ## Alternative: Token Authentication For environments with dynamic IPs, consider using token-based auth instead: ### When to Use Tokens Instead * Developer laptops (IP changes frequently) * Mobile devices * Serverless functions (Lambda, Cloud Functions) * Behind CDN/proxy with changing IPs ### How to Set Up The recommended path is an OAuth access token — the [SDK](/sdks/overview) mints and refreshes it for you after [`knoxcall login`](/api-reference/authentication): ```typescript theme={"dark"} import { KnoxCall } from "@knoxcall/sdk"; const client = new KnoxCall(); // credentials from `knoxcall login` const resp = await client.call("user-api", { path: "/api/users" }); ``` Or raw curl with a minted token: ```bash theme={"dark"} curl https://a1b2c3d4.acme.knoxcall.com/api/users \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: user-api" ``` **Legacy API key:** create one in **Settings** → **Tenant** tab → **API Keys**, enable **"Allow any IP"** for dev/test keys, and send it in the `x-knoxcall-key` header: ```bash theme={"dark"} curl https://a1b2c3d4.acme.knoxcall.com/api/users \ -H "x-knoxcall-key: tk_test_abc123..." \ -H "x-knoxcall-route: user-api" ``` No IP whitelisting needed! **Note:** Only use "Allow any IP" for dev/test keys, never production. *** ## Security Best Practices ### ✅ Do 1. **Use descriptive names** - Know what each client is for 2. **Add descriptions** - Document IP source and purpose 3. **Use static IPs for production** - Elastic IPs, not dynamic 4. **Separate environments** - Different clients for prod/staging/dev 5. **Regular audits** - Review and remove unused clients monthly 6. **Least privilege** - Only assign to routes that need it ### ❌ Don't 1. **Don't use 0.0.0.0/0** - This allows ALL IPs (defeats purpose!) 2. **Don't share clients** - Production and dev should have separate clients 3. **Don't ignore changes** - Update IPs promptly when they change 4. **Don't use generic names** - "client1", "test" are confusing 5. **Don't allow dev IPs in prod** - Keep environments isolated *** ## Troubleshooting ### Client Created But Requests Rejected **Cause:** Client not assigned to route. **Solution:** 1. Go to route → **Clients** tab 2. Select environment 3. Assign your client 4. Try again ### IP Keeps Changing **Cause:** Dynamic IP from ISP (home/office internet). **Solution:** * **Option A:** Update IP each time it changes * **Option B:** Use token auth (SDK/OAuth, or a legacy API key with "Allow any IP") * **Option C:** Use VPN with static IP ### Wrong Client Authorized **Symptom:** Wrong server can access route. **Solution:** 1. Review **Clients** tab for route 2. Remove unauthorized clients 3. Only keep necessary clients assigned *** ## Quick Reference | Task | Steps | | - | - | | **Create client** | Resources → Clients → Create | | **Edit client** | Clients → Click name → Edit | | **Delete client** | Clients → Click name → Delete | | **Assign to route** | Route → Clients tab → Assign | | **Disable client** | Edit → Toggle Enabled OFF | | **Check current IP** | `curl https://api.ipify.org` | *** ## Next Steps Understand IP whitelisting basics Server vs User types explained Understanding IP ranges (coming soon) Fix common IP problems *** **Pro Tip:** For production, always use **Server** type with static IPs. For development, use **User** type or token auth to handle dynamic IPs easily. # What are Clients? Source: https://docs.knoxcall.com/essentials/clients/what-are-clients Understand IP whitelisting and client authorization in KnoxCall. Learn how clients provide network-level security. # What are Clients? A **client** in KnoxCall is an authorized IP address or network that's allowed to make requests through your routes. Think of clients as your "guest list" for API access. ## The Bouncer Analogy Imagine KnoxCall as a nightclub bouncer: * **Without clients**: Anyone on the internet can try to access your routes * **With clients**: Only IPs on your whitelist get through the door ```text theme={"dark"} Internet User (Random IP) → 🚫 Blocked Your Server (Whitelisted) → ✅ Allowed ``` *** ## What Problem Do Clients Solve? ### The Problem: Open Access Without client authorization, your KnoxCall routes are accessible to anyone who knows the URL: ```bash theme={"dark"} # Anyone can call this if they know your subdomain curl https://a1b2c3d4.acme.knoxcall.com/api/users \ -H "x-knoxcall-route: user-api" ``` **Risks:** * Attackers can spam your routes * Unexpected traffic costs * Potential DDoS attacks * Unauthorized access attempts ### The Solution: IP Whitelisting With client authorization enabled, only specific IP addresses can access your routes: ```text theme={"dark"} Your Production Server (52.123.45.67) → ✅ Whitelisted Your Office Network (203.14.0.0/16) → ✅ Whitelisted Random Internet User (100.5.8.200) → ❌ BLOCKED Attacker (45.67.89.10) → ❌ BLOCKED ``` *** ## How Client Authorization Works ### The Request Flow ```text theme={"dark"} 1. Request arrives at KnoxCall ↓ 2. KnoxCall extracts source IP address ↓ 3. Checks if IP matches any authorized client ↓ 4. If YES → Process route If NO → Return 403 Forbidden ``` ### Example **Your clients:** * Production server: `52.123.45.67` * Staging server: `54.200.11.22` **Request from production (52.123.45.67):** ```bash theme={"dark"} curl https://a1b2c3d4.acme.knoxcall.com/api/users \ -H "x-knoxcall-route: user-api" # ✅ Success: 200 OK ``` **Request from unknown IP (100.5.8.200):** ```bash theme={"dark"} curl https://a1b2c3d4.acme.knoxcall.com/api/users \ -H "x-knoxcall-route: user-api" # ❌ Error: 403 Forbidden # "IP address not authorized for this route" ``` *** ## Client Types KnoxCall supports two types of clients: ### 1. Server (Recommended for Production) **Best for**: Production servers with static IPs, office networks, cloud subnets **Characteristics:** * Fixed IP address or CIDR range (e.g., AWS Elastic IP or VPC subnet) * Never changes * Most reliable authorization method **Examples:** ```text theme={"dark"} Name: production-api-server Type: Server IP: 52.123.45.67 Description: Main API server (AWS us-east-1) ``` ```text theme={"dark"} Name: office-network Type: Server IP: 192.168.1.0/24 Description: Company office network (all employees) ``` ### 2. User (For Development) **Best for**: Developer machines, dynamic IPs **Characteristics:** * IP may change occasionally * Great for local development * Can be updated when IP changes **Example:** ```text theme={"dark"} Name: john-dev-laptop Type: User IP: 203.45.67.89 Description: John's development laptop ``` Both Server and User types support single IPs and CIDR ranges. The type is a label to help you organize — Server for infrastructure, User for people. *** ## IP Address Formats ### Single IP Address Most common format for individual servers: ```text theme={"dark"} 52.123.45.67 ``` Authorizes exactly one IP address. ### CIDR Notation (IP Ranges) For networks and ranges: ```text theme={"dark"} 192.168.1.0/24 ``` **What does `/24` mean?** * Authorizes 192.168.1.0 through 192.168.1.255 * That's 256 IP addresses **Common CIDR ranges:** * `/32` = 1 IP (e.g., 192.168.1.1/32) * `/24` = 256 IPs (e.g., 192.168.1.0/24) * `/16` = 65,536 IPs (e.g., 10.0.0.0/16) **Example office network:** ```text theme={"dark"} Name: acme-corp-office IP: 203.14.0.0/16 Description: All office locations ``` This authorizes: * 203.14.0.1 * 203.14.0.2 * ... (all IPs from 203.14.0.0 to 203.14.255.255) *** ## Per-Environment Client Authorization One of KnoxCall's most powerful features: **different clients for different environments**. ### The Concept You can assign different sets of clients to: * Production environment * Staging environment * Development environment ### Real-World Example **Route**: `user-api` **Production environment clients:** * `52.123.45.67` (prod server) * `52.123.45.68` (prod server backup) **Staging environment clients:** * `54.200.11.22` (staging server) * `203.45.67.89` (your dev laptop) **Result:** * Production traffic comes from prod IPs only * Staging can be accessed from staging server AND your laptop * Clear separation between environments *** ## Common Use Cases ### 1. Production API Server **Scenario**: Your backend server needs to call third-party APIs **Setup:** ```text theme={"dark"} Client: Name: prod-api-server-1 Type: Server IP: 52.123.45.67 (AWS Elastic IP) Route: stripe-payments Assigned Clients: prod-api-server-1 ``` **Benefit**: Only your production server can charge customers via Stripe route. ### 2. Office Network **Scenario**: Multiple team members need to test webhooks **Setup:** ```text theme={"dark"} Client: Name: acme-office-network Type: Server IP: 203.14.0.0/24 (covers all office computers) Route: test-webhooks Assigned Clients: acme-office-network ``` **Benefit**: Everyone in the office can test, but external users can't. ### 3. Partner Integration **Scenario**: External partner needs to send webhooks to your system **Setup:** ```text theme={"dark"} Client: Name: partner-acme-corp Type: Server IP: 100.25.30.45 (partner's webhook server) Route: partner-webhooks Assigned Clients: partner-acme-corp ``` **Benefit**: Partner can send webhooks, but no one else can. ### 4. Development Team **Scenario**: 5 developers working remotely with dynamic IPs **Option A: Individual clients (tedious)** ```text theme={"dark"} john-laptop: 203.45.67.89 jane-laptop: 180.123.45.67 bob-laptop: 192.5.8.100 ... ``` **Option B: Use API key auth (better)** * Create test API key with `skip_ip_check: true` * Developers use API key instead of IP whitelist * More flexible for dynamic IPs *** ## Client Priority and Fallback ### Assignment Levels Clients can be assigned at two levels: 1. **Route-level (global)**: All environments use these clients 2. **Environment-level**: Specific clients per environment **Environment-specific always wins:** ```text theme={"dark"} Route "user-api": Global clients: [prod-server, staging-server] Environment "production": Clients: [prod-server only] Environment "staging": Clients: [staging-server, dev-laptop] ``` **Result:** * Production: Only `prod-server` allowed * Staging: `staging-server` and `dev-laptop` allowed * Global clients ignored when environment has specific clients *** ## Security Best Practices ### ✅ Do * **Use specific IPs** - Don't whitelist entire internet ranges * **Use CIDR for networks** - Easier to manage office networks * **Separate environments** - Different clients for prod/staging/dev * **Use descriptive names** - `prod-api-server-1` not `server1` * **Document why** - Add descriptions explaining each client's purpose * **Regular audits** - Remove unused clients monthly ### ❌ Don't * **Don't use 0.0.0.0/0** - This allows ALL IPs (defeats the purpose!) * **Don't reuse clients** - Production and staging should have separate clients * **Don't forget to update** - When IPs change, update immediately * **Don't whitelist untrusted IPs** - Only authorize systems you control * **Don't use generic names** - `client1`, `client2` are unmaintainable *** ## IP Detection KnoxCall automatically detects the source IP from these headers (in order): 1. **cf-connecting-ip** (Cloudflare - most reliable) 2. **x-forwarded-for** (Standard proxy header - first IP only) 3. **Socket remote address** (Direct connection fallback) This ensures accurate IP detection even behind proxies and CDNs. *** ## When NOT to Use Clients Client authorization might not be necessary if: 1. **Public webhooks** - Stripe, Shopify send from many IPs (use signature validation instead) 2. **CDN origins** - Cloudflare, Fastly have changing IP ranges (use request signing) 3. **Mobile apps** - Users have random IPs (use API key auth) 4. **Serverless functions** - Lambda, Cloud Functions have dynamic IPs (use API keys) **Alternative**: Use request signing (`x-knoxcall-signature`) for these scenarios. *** ## Quick Reference | Client Type | Best For | Example IP | Format | | - | - | - | - | | Server | Production servers, office networks | `52.123.45.67` or `192.168.1.0/24` | Single IP or CIDR range | | User | Developer machines | `203.45.67.89` | Single IP or CIDR range | *** ## Next Steps Step-by-step tutorial Create, edit, delete, and assign clients Deep dive into Server, User, and Network types Understanding IP ranges *** **Key Takeaway**: Clients are your first line of defense. They provide network-level security by ensuring only authorized IPs can access your routes. # Collection Variables Source: https://docs.knoxcall.com/essentials/collections/collection-variables Use variables to share configuration values and secrets across routes in a collection # Collection Variables ## Overview Collection variables are reusable key-value pairs that can be referenced across all routes within a collection. They provide a centralized way to manage configuration values and secret references, ensuring consistency and reducing duplication. ## Variable Types ### Value Variables Standard variables that store plain-text configuration values like API endpoints, timeout settings, or feature flags. **Example:** ```text theme={"dark"} Key: api_timeout Value: 30000 ``` ### Secret Variables Variables that reference encrypted secrets stored in your KnoxCall vault. These are ideal for sensitive data like API keys, passwords, or tokens. **Example:** ```text theme={"dark"} Key: stripe_api_key Secret: Stripe Production Key ``` When a route uses this variable, KnoxCall automatically resolves the secret value from the vault. ## Creating Variables Open your collection and go to the **Variables** tab Click the **+** button in the Variables card header * **Key**: Enter a unique identifier (alphanumeric and underscores only) * **Type**: Choose "Value" for plain text or "Secret" for sensitive data * **Value/Secret**: Enter the value or select an existing secret * **Description**: (Optional) Add notes about the variable's purpose Click **Create** to save the variable ## Using Variables in Routes Reference collection variables in your routes using the template syntax: ```text theme={"dark"} {{var:variable_name}} ``` ### Supported Fields Collection variables can be used in: * **Target URLs** - `https://api.example.com/{{var:api_version}}/users` * **Headers** - `Authorization: Bearer {{var:auth_token}}` * **Query Parameters** - `?api_key={{var:api_key}}` * **Request Bodies** - JSON values that reference variables ### Example: Using Variables in a Route **Collection Variables:** ```text theme={"dark"} base_domain = api.stripe.com stripe_secret = [Secret: Stripe Production Key] api_version = v1 ``` **Route Configuration:** ```text theme={"dark"} Target URL: https://{{var:base_domain}}/{{var:api_version}}/charges Headers: Authorization: Bearer {{var:stripe_secret}} Content-Type: application/json ``` When a request is proxied, KnoxCall resolves: ```text theme={"dark"} Target URL: https://api.stripe.com/v1/charges Headers: Authorization: Bearer sk_live_EXAMPLE_xxxxxxxxxxxxx Content-Type: application/json ``` ## Variable Resolution ### Priority Order When a variable name appears in multiple places, KnoxCall resolves it in this order: 1. **Environment-specific overrides** (coming soon) 2. **Collection variables** (current) 3. **Route-level variables** (future feature) ### Environment Context When using [Environment-Specific Secrets](/essentials/secrets/environment-specific-secrets), collection variables automatically resolve to the correct environment's secret value based on the active environment of the route. **Example:** * Variable `api_key` references secret "API Key" * Secret "API Key" has different values for `production` and `staging` * When route is accessed in `production` environment → uses production API key * When route is accessed in `staging` environment → uses staging API key ## Best Practices Choose clear, self-documenting variable names like `stripe_webhook_secret` instead of `secret1` Always use secret variables (not value variables) for API keys, passwords, tokens, and other sensitive data Use the Description field to explain what each variable is for and how it should be used Organize variables by prefix: `stripe_api_key`, `stripe_webhook_secret`, `stripe_account_id` If multiple collections need the same variable, consider if they should be merged into one collection ## Common Use Cases ### API Versioning ```text theme={"dark"} base_url = https://api.example.com api_version = v2 Route: {{var:base_url}}/{{var:api_version}}/endpoint ``` ### Multi-Tenant Configuration ```text theme={"dark"} tenant_id = customer_123 tenant_api_key = [Secret: Customer 123 API Key] Header: X-Tenant-ID: {{var:tenant_id}} Header: Authorization: Bearer {{var:tenant_api_key}} ``` ### Feature Flags ```text theme={"dark"} enable_new_auth_flow = true max_retry_attempts = 3 Query: ?use_new_auth={{var:enable_new_auth_flow}} Query: ?max_retries={{var:max_retry_attempts}} ``` ### Environment-Specific Endpoints ```text theme={"dark"} database_url = [Secret: Production DB Connection String] cache_ttl = 3600 Config: connection={{var:database_url}}&ttl={{var:cache_ttl}} ``` ## Managing Variables ### Editing Variables 1. Navigate to the **Variables** tab in your collection 2. Click on the variable row to open the editor 3. Modify the value or description 4. Click **Update** to save changes ### Copying Variable References 1. In the Variables tab, click the **⋯** menu for a variable 2. Select **Copy reference** 3. The variable syntax `{{var:key_name}}` is copied to your clipboard ### Deleting Variables Deleting a variable will break any routes that reference it. Always verify usage before deletion. 1. Click the **⋯** menu for the variable 2. Select **Delete** 3. Confirm the deletion ## Troubleshooting **Symptoms:** Route shows `{{var:key_name}}` in logs instead of the actual value **Solutions:** * Verify the variable key is spelled correctly (case-sensitive) * Check that the variable exists in the collection * Ensure the route is assigned to the correct collection **Symptoms:** Secret variable resolves to empty string **Solutions:** * Verify the secret exists and has a value * Check that the secret is not deleted * For environment-specific secrets, ensure the secret has a value for the active environment **Symptoms:** Can't create variable with certain characters **Solutions:** * Variable keys must be alphanumeric plus underscores only * Use `api_key` instead of `api-key` or `api.key` * Keys cannot start with a number ## Next Steps Learn how to use different secret values per environment Configure default settings per environment at the collection level # What are Collections? Source: https://docs.knoxcall.com/essentials/collections/collections-overview Organize and manage your routes, secrets, and environment configurations with Collections # What are Collections? ## Overview Collections in KnoxCall are organizational containers that help you group related routes, secrets, and variables together. They provide a structured way to manage shared configurations across multiple routes and maintain consistency in your API proxy setup. The **Secrets** page now uses a two-level hierarchy with collections as the top level. Clicking a collection reveals its secrets. Secrets that don't belong to any collection appear under an **Uncollected** group. When creating a new secret, you'll be prompted to assign it to a collection — or type a new name to create one on the fly. ## Key Features ### Route Organization Group related routes together for better project management. All routes in a collection can share common settings and configurations. ### Shared Variables Define variables once and use them across all routes in the collection. Variables can be: * **Static values** - Reusable configuration values * **Secret references** - Point to encrypted secrets for sensitive data ### Environment Defaults Set default configurations per environment that apply to all routes in the collection: * **Base URLs** - Default target URLs for each environment * **Default Clients** - Automatically attach authorized clients to routes * **Expected Environments** - Auto-provision routes with specific environments ### Centralized Secret Management Attach secrets to a collection and reference them across all routes, ensuring consistent credential management. ## Use Cases Group routes for different microservices (auth, payments, notifications) in separate collections, each with their own secrets and environment configurations. Create collections per client or tenant, with isolated credentials and configuration variables. Define different base URLs and client permissions per environment (dev, staging, production) at the collection level. Organize routes by team or project area, making it easier for different teams to manage their own API integrations. ## How Collections Work When you create a collection, you can: 1. **Add Routes** - Assign existing routes or create new ones directly in the collection 2. **Define Variables** - Create reusable key-value pairs or secret references 3. **Configure Environments** - Set base URLs and default clients per environment 4. **Attach Secrets** - Move secrets into the collection for shared access. Use the inline **Move to Collection** submenu accessible from any secret row in the Secrets list for quick assignment without opening the secret detail. 5. **Apply Defaults** - Bulk-apply environment configurations to all routes Routes in a collection can reference collection variables using the syntax: ```text theme={"dark"} {{var:variable_name}} ``` ## Benefits Ensure all related routes use the same credentials and configurations Easily add new routes that inherit collection settings Update shared variables and secrets in one place Streamline multi-environment deployments with collection defaults ## Next Steps Learn how to create and manage collections Work with variables and secret references # Managing Collections Source: https://docs.knoxcall.com/essentials/collections/managing-collections Create, configure, and organize your routes with Collections # Managing Collections ## Creating a Collection From the main navigation, click **Collections** in the sidebar Click the **+ Create Collection** button * **Name**: Enter a descriptive name for your collection * **Icon**: Choose an icon to visually identify the collection * **Description**: (Optional) Add notes about the collection's purpose Click **Create** to save your new collection You can also create a collection on the fly when creating a new secret. In the secret creation form, type a new name in the collection picker to create and assign a collection in one step — no need to pre-create it. ## Collection Tabs Each collection has multiple tabs for different aspects of configuration: ### Overview View and edit basic collection information: * Collection name and icon * Description * Creation date and metadata ### Routes Manage routes within the collection: * View all routes in the collection * Add existing routes * Create new routes directly in the collection * View route status (enabled/disabled) ### Secrets Manage secrets attached to the collection: * View all secrets in the collection * Move secrets from other collections * Create new secrets in this collection * Secrets can be referenced by collection variables **Quick move from the Secrets list:** You don't need to open a collection to assign secrets to it. From the main Secrets page, hover over any secret row and use the **Move to Collection** submenu to assign it in-place. This works across collections — you can reassign a secret from one collection to another without opening either. ### Variables Create and manage reusable variables: * Define value variables (plain text) * Define secret variables (reference encrypted secrets) * Copy variable references to use in routes * See [Collection Variables](/essentials/collections/collection-variables) for details ### Environments Configure environment-specific defaults: * **Expected Environments**: Auto-provision routes with selected environments * **Base URL**: Default target URL per environment * **Default Clients**: Automatically attach clients to routes in each environment * See [Collection Environment Defaults](/essentials/environments/collection-environment-defaults) for details ### Bulk Operations (Coming soon) Apply changes to all routes in the collection at once. ## Adding Routes to a Collection ### Option 1: During Route Creation When creating a new route: 1. Fill in the route details 2. In the **Collection** dropdown, select your collection 3. Save the route The route will automatically inherit any collection environment defaults. ### Option 2: From the Collection Page 1. Navigate to your collection 2. Go to the **Routes** tab 3. Click the **+** button 4. Select **Create new route** or **Add existing route** ### Option 3: From an Existing Route 1. Open any route's detail page 2. Click the **Collection** dropdown 3. Select the target collection 4. Save changes ## Moving Secrets to a Collection Secrets can be organized by moving them into collections: Open your collection and go to the **Secrets** tab Click the **+** button in the Secrets card header Choose a secret from the list of available secrets Click **Move to this collection** to complete the transfer Moving a secret to a collection doesn't break existing routes — secrets can still be used by any route that references them. ## Configuring Environment Defaults Collections can define default settings that apply to all routes when they're created or updated: ### Setting Expected Environments 1. Go to the **Environments** tab 2. In the **Expected environments** section, click on environment names to toggle them 3. Selected environments will be auto-provisioned for new routes added to the collection Expected environments are indicated with a checkmark and colored background ### Configuring Per-Environment Defaults For each environment, you can configure: Use the environment dropdown to choose which environment to configure Enter the default target URL for routes in this environment Select clients that should automatically be attached to routes Click **Save** to apply the environment configuration ### Applying Defaults to Existing Routes After configuring environment defaults, you can apply them to existing routes: 1. Configure your environment defaults 2. Review the sync status showing how many routes are out of sync 3. Click **Apply to routes** to bulk-update all routes in the collection Applying defaults will overwrite the base URL and client assignments for existing routes in that environment. Review carefully before applying. ## Editing Collection Details 1. Navigate to your collection 2. Go to the **Overview** tab 3. Edit the collection name, icon, or description 4. Changes are saved automatically ## Deleting a Collection Deleting a collection does NOT delete the routes, secrets, or variables within it. They will simply no longer be associated with a collection. To delete a collection: 1. Navigate to the Collections list 2. Click the **⋯** menu for the collection 3. Select **Delete collection** 4. Confirm the deletion ## Collection Icons Choose from a wide variety of icons to visually identify your collections: Folders, Archives, Boxes, Databases Code, Terminal, Git, Webhooks Shields, Locks, Keys, Fingerprints Carts, Cards, Wallets, Receipts Mail, Messages, Phones, Notifications Charts, Gauges, Trends, Analytics ...and many more! Select an icon that best represents your collection's purpose. ## Best Practices Create collections for each distinct project, microservice, or API integration: * `Stripe Payments` * `SendGrid Email` * `Auth0 Authentication` Establish a naming convention for collections: * `[Service] - [Environment]` → `Shopify - Production` * `[Client] - [Integration]` → `Acme Corp - Payments` * `[Team] - [Project]` → `Platform Team - API Gateway` Set base URLs and default clients at the collection level to ensure new routes are configured correctly from the start Move related secrets into the same collection as the routes that use them for easier management Use the Description field to explain what the collection is for, which team owns it, and any special considerations ## Example Collection Setups ### E-commerce Integration ```text theme={"dark"} Collection: Shopify Store Icon: Shopping Bag Routes: Product Sync, Order Webhook, Inventory Update Secrets: Shopify API Key, Shopify Webhook Secret Variables: - shop_domain = acme-store.myshopify.com - api_version = 2024-01 Expected Environments: staging, production ``` ### Payment Processing ```text theme={"dark"} Collection: Stripe Payments Icon: Credit Card Routes: Create Charge, Refund, List Customers Secrets: Stripe Secret Key (prod), Stripe Secret Key (test) Variables: - stripe_api_key = [Secret: Stripe Secret Key] - webhook_endpoint = /webhooks/stripe Expected Environments: sandbox, production ``` ### Notification Service ```text theme={"dark"} Collection: SendGrid Email Icon: Mail Routes: Send Email, Send Bulk, Get Templates Secrets: SendGrid API Key Variables: - from_email = noreply@example.com - default_template_id = d-abc123 Expected Environments: development, production ``` ## Next Steps Learn how to use variables in your routes Configure environment-specific settings Add routes to your collection Create and organize secrets # Crypto Keys overview Source: https://docs.knoxcall.com/essentials/crypto-keys/crypto-keys-overview Encryption-as-a-Service for your application data. Encrypt, decrypt, and rotate keys via API — without your apps ever seeing the key material. # Crypto Keys overview KnoxCall's **Crypto Keys** feature is encryption-as-a-service. You create a named key, then encrypt and decrypt data through KnoxCall's API. Your applications **never see the key material** — they call `/encrypt` and `/decrypt` and get back ciphertext / plaintext. The Vault parallel: this is KnoxCall's `transit` engine. Use it for: * **Encrypting customer-record fields** at the application layer (PII, PHI, financial identifiers). * **Decoupling apps from key storage** — change the key, rotate it, destroy it without touching application code. * **Cryptographic erasure** — destroy a key version and every piece of ciphertext under it becomes permanently unreadable. Powerful right-to-be-forgotten primitive. * **Centralised audit** — every encrypt / decrypt call lands in the audit log. ## Quick example ```bash theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) # Create a named key curl -X POST https://api.knoxcall.com/v1/crypto/keys \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "customer-pii", "mode": "cloud-only" }' # Encrypt some plaintext curl -X POST https://api.knoxcall.com/v1/crypto/keys/customer-pii/encrypt \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "plaintext": "Jane Doe, 415-555-9311" }' # → { "ciphertext": "knoxcall:v1:G2x...", "key_version": 1 } # Decrypt it (any version that hasn't been destroyed) curl -X POST https://api.knoxcall.com/v1/crypto/keys/customer-pii/decrypt \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "ciphertext": "knoxcall:v1:G2x..." }' # → { "plaintext": "Jane Doe, 415-555-9311", "plaintext_b64": "SmFuZSBEb2UsIDQxNS01NTUtOTMxMQ==", "key_version": 1 } ``` The ciphertext format is `knoxcall:v:` where `N` is the key version that produced it. Vault's `vault:v:...` prefix is also accepted on input — you can migrate ciphertext stored under Vault's transit engine without re-encrypting. ## Algorithms Pick a `key_type` when you create a key. The right choice depends on what you'll use it for: | `key_type` | Use case | Operations | | - | - | - | | `aes256-gcm` | Encrypt/decrypt application data | `encrypt`, `decrypt`, `rewrap` | | `hmac-sha256`, `hmac-sha512` | HMAC over a payload (request signing, webhook auth) | `hmacSign`, `hmacVerify`, `signJwt`, `verifyJwt` | | `rsa-2048`, `rsa-3072`, `rsa-4096` | Asymmetric signing — JWTs (RS256/PS256), document signing | `sign`, `verify`, `signJwt`, `verifyJwt`, `getPublicKey` | | `ecdsa-p256`, `ecdsa-p384` | Asymmetric signing — JWTs (ES256/ES384), faster than RSA | `sign`, `verify`, `signJwt`, `verifyJwt`, `getPublicKey` | | `ed25519` | Modern asymmetric signing — JWTs (EdDSA), constant-time | `sign`, `verify`, `signJwt`, `verifyJwt`, `getPublicKey` | **Cryptographic primitives:** * AES-256-GCM. Ciphertext payload `iv[12] || authTag[16] || ciphertext[n]`. Format-prefix `knoxcall:v:`. * HMAC: SHA-256 (32-byte tag) or SHA-512 (64-byte tag). * RSA: PKCS#1 v1.5 padding by default for JWT compatibility (RS256). PSS available via `rsa_padding: 'pss'` for raw signing, or `header_overrides: { alg: 'PS256' }` (/ `PS384`, `PS512`) for JWT signing. * ECDSA: deterministic per RFC 6979. * Ed25519: standard EdDSA. **Public key export:** asymmetric keys expose `GET /v1/crypto/keys/{name}/public-key` returning both PEM (SPKI) and JWK. External services verify your signatures using the public key without ever needing the private side. ## JWT signing and verification Use a key directly to sign / verify JWTs: ```bash theme={"dark"} # Sign curl -X POST https://api.knoxcall.com/v1/crypto/keys/jwt-signer/jwt \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "claims": { "sub": "user-123", "exp": 9999999999 } }' # → { "data": { "token": "eyJ...", "key_version": 1, "alg": "RS256" }, "meta": { "request_id": "..." } } # Verify curl -X POST https://api.knoxcall.com/v1/crypto/keys/jwt-signer/jwt/verify \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "token": "eyJ..." }' # → { "data": { "valid": true, "claims": { ... }, "key_version": 1, "alg": "RS256" }, "meta": { "request_id": "..." } } ``` The `alg` is derived from the key (`hmac-sha256` → `HS256`, `rsa-2048` → `RS256`, `ecdsa-p256` → `ES256`, `ed25519` → `EdDSA`). The verifier: * **rejects `alg: 'none'`** unconditionally, * **rejects algorithm confusion** — an `HS256` token won't verify against an RSA key, * **binds `kid`** so the version that issued the token is the one used to verify, * **validates standard claims** when you pass `expected` (issuer, audience, subject, expiry, clock skew). **External verify** (the killer test): export the public key, hand it to `jsonwebtoken` in Node or `pyjwt` in Python, and verify a token KnoxCall signed. If those libraries accept it, the implementation is end-to-end correct. ## Webhook signing helper For Stripe-format webhook signatures (v1 supports the Stripe `t=,v1=` format; additional formats are planned), use the helper: ```bash theme={"dark"} curl -X POST https://api.knoxcall.com/v1/crypto/keys/stripe-webhook-secret/webhook-sign \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "payload": "{\"event\":\"order.completed\"}" }' # → { "data": { "signature_header": "t=1735000000,v1=", "timestamp_seconds": 1735000000, "key_version": 1, "format": "stripe" }, "meta": { "request_id": "..." } } ``` This is the same signing logic [Webhook Signing](/essentials/webhook-signing/overview) uses for outbound subscriptions, exposed as a primitive so you can sign payloads with full control over headers and timing. ## Modes When you create a key, pick a **mode**: ### `cloud-only` (recommended) Key material stays on KnoxCall's control plane. Every encrypt / decrypt is a network call. * ✅ Strongest blast-radius story — a self-hosted agent or local app process can never leak the key. * ⚠️ Higher latency — every operation is a round-trip to KnoxCall. * 👍 Use for: high-value secrets (PHI, financial identifiers, credentials), audit-driven encryption (every call is logged), keys you might ever want to revoke. ### `bundled` Key material is shipped to self-hosted agents in their session bundle (encrypted with the session key) so the agent can do encrypt / decrypt **locally**, without the call leaving the customer's network. * ✅ Low-latency local encrypt / decrypt. * ✅ Same trust model as KnoxCall's secret-injection feature today — in-memory, hour-bounded. * ⚠️ A compromised agent process can read the in-memory key for the duration of its session. * 👍 Use for: hot-path encryption inside a trusted self-hosted environment where round-trips would be too slow. The mode is a one-shot decision per key — to switch, create a new key in the other mode and `rewrap` ciphertext to it. ## Operations | Op | What it does | When to use | | - | - | - | | `encrypt` | Plaintext → ciphertext, tagged with current version | Every encryption op | | `decrypt` | Ciphertext → plaintext, using whatever version the prefix names | Every decryption op | | `rotate` | Mints a new active version, retires the previous | Periodic key hygiene; after a suspected compromise | | `rewrap` | Decrypts with old version + re-encrypts with active. **Plaintext never crosses the wire.** | Migrating old ciphertext to a fresh version after a rotate | | `destroyVersion` | Cryptographic erasure of a specific version | Right-to-be-forgotten; after rewrap migration is done | | `hmacSign` / `hmacVerify` | Keyed HMAC over a payload | API request signing, message authentication | | `sign` / `verify` | Asymmetric sign / verify (RSA, ECDSA, Ed25519) | Document signing, custom-format signing | | `signJwt` / `verifyJwt` | Issue or validate a JWT | Service-to-service auth, customer-facing tokens | | `webhookSign` | Stripe-format `t=...,v1=...` header | Outbound webhook signing primitive | | `getPublicKey` | Export the public side of an asymmetric key (PEM + JWK) | Hand to external verifiers | ## Versioning at a glance ```text theme={"dark"} customer-pii (current_version = 3) v3 active ← encrypt uses this v2 retired ← can still decrypt v2 ciphertext v1 destroyed ← v1 ciphertext is permanently unreadable ``` * **Encrypt** always uses the active version. * **Decrypt** uses the version named in the ciphertext prefix. * **Retired** versions stay around so legacy ciphertext keeps working. * **Destroyed** versions are cryptographic erasure — that ciphertext is permanently unreadable. (You can't destroy the active version; rotate first.) `deletion_allowed` is a per-key safety latch, `false` on every new key. A version can only be destroyed while it is `true`, so an erasure nothing can undo is never one call away. Raise it with `PATCH /v1/crypto/keys/` (or the **Allow version destruction** toggle on the key's Versions tab) and lower it again straight after — see [Rotation & versioning](/essentials/crypto-keys/rotation-and-versioning#deletion-allowed). See [Rotation & versioning](/essentials/crypto-keys/rotation-and-versioning) for the full lifecycle. ## How the keys themselves are protected Each version is stored wrapped with your [tenant master key](/essentials/key-management/tenant-master-key) (KCT1 envelope format). When KnoxCall needs to use a version, it unwraps it on the fly into an in-memory cache (5-minute TTL). If you cryptographically erase your **tenant master key**, every transit key under it becomes permanently unreadable — by construction. There's no separate "delete all keys" step; the wrapping chain does it. ## Audit trail Every encrypt / decrypt / rotate / destroy lands in the audit log under `resource_type='transit'` with the key name, version, and caller. You can answer "who decrypted PII record 12345 last Tuesday?" in two log queries. ## Plays well with... * **Routes** — store an encrypted secret in a route header by encrypting it with a transit key and decrypting on egress. * **Databases** — encrypt PII in your own DB schema; decrypt only on read. * **[Vaults](/essentials/vaults/overview)** — every vault gets its own AES-256-GCM Crypto Key. Rotate the vault → rotates the underlying transit key. * **[Webhook Signing (outbound)](/essentials/webhook-signing/overview)** — sign payloads with `hmacSign` (HMAC formats) or `sign` (RSA/ECDSA/Ed25519). `webhookSign` returns a Stripe-shaped header in one call. * **[Inbound Webhooks](/essentials/inbound-webhooks/overview)** — point a subscription at an HMAC key here instead of pre-sharing a secret; rotation flows through the Crypto Keys lifecycle. * **[Ephemeral Proxy](/essentials/ephemeral-proxy/overview)** — `{{ encrypted | json: ... }}` template references encrypt small JSON blobs with a Crypto Key. ## Who can decrypt and rewrap Decrypting with a key, and rewrapping a ciphertext onto the key's latest version (which opens the ciphertext on the server to re-seal it), are reveals. In the dashboard this is a **Class B reveal**: only an **Owner** or an **Admin** can do it (an Editor cannot, even with **Reveal secrets** turned on; that setting covers secret values, certificates and credentialed workflow tests only), and every use asks for a fresh second factor (an authenticator code, a passkey or a security key; an emailed code does not count). Each use writes a `plaintext.reveal` entry to the audit log **before** anything is shown, and counts against the same per-person reveal budget as showing a secret's value (60 per 5 minutes). If the audit entry cannot be written, or the budget cannot be counted, the request is refused rather than served. On the Management API (`/v1`) a credential still needs the matching permission. A token issued to a person never carries `transit:decrypt`; transit rewrap on `/v1` is unchanged and does not take this check. Encrypting, signing and verifying are not reveals and are unchanged. ## Next steps * [Rotation & versioning →](/essentials/crypto-keys/rotation-and-versioning) * [Tenant master key →](/essentials/key-management/tenant-master-key) * [API reference →](/api-reference/overview) # Rotation and versioning Source: https://docs.knoxcall.com/essentials/crypto-keys/rotation-and-versioning How crypto-key versions move from active to retired to destroyed, and the rewrap flow that lets you migrate old ciphertext without ever exposing plaintext. # Rotation and versioning Every crypto key in KnoxCall is **versioned**. New encryptions always use the latest active version; old ciphertext keeps decrypting against whatever version originally produced it. Rotation moves the active pointer forward without breaking anything that's already encrypted. ## The version lifecycle ```text theme={"dark"} v1 active ──rotate──▶ v1 retired + v2 active ──rotate──▶ v2 retired + v3 active ──destroyVersion──▶ v1 destroyed ``` Each version is in one of three states: * **`active`** — used for new encryptions. Exactly one per key at any time. * **`retired`** — kept around so old ciphertext under that version still decrypts. As many as you've rotated. * **`destroyed`** — cryptographic erasure. The wrapped key bytes are wiped and the row stays as an audit marker. Ciphertext under a destroyed version is **permanently unreadable**. ## Rotating a key ```bash theme={"dark"} curl -X POST https://api.knoxcall.com/admin/crypto/keys/customer-pii/rotate \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "X-Tenant-ID: $TENANT_ID" # → { "new_version": 2 } ``` What happens, atomically inside a single transaction: 1. A new 256-bit key is generated and wrapped with the tenant master key. 2. The new version row is inserted as `active`. 3. The previous active version is flipped to `retired`. 4. The key's `current_version` pointer is updated. `encrypt` calls from this point forward emit `knoxcall:v2:...` ciphertext. `decrypt` calls handle either `v1` or `v2` ciphertext seamlessly. ## When to rotate | Reason | How often | | - | - | | Routine hygiene (compliance) | Every 90 days | | After a personnel change with key access | Immediately | | After a suspected compromise | Immediately + rewrap + destroy old version | | Pre-emptive before destroying the active version | Always (you can't destroy `active`) | Rotating is cheap. Decrypting from old versions is also cheap. The only meaningful "cost" of holding many retired versions is audit-trail clutter. ## Rewrap: migrate ciphertext to a new version Rotating doesn't re-encrypt existing data. Old ciphertext keeps decrypting against retired versions. To migrate it: ```bash theme={"dark"} curl -X POST https://api.knoxcall.com/admin/crypto/keys/customer-pii/rewrap \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "X-Tenant-ID: $TENANT_ID" \ -H "Content-Type: application/json" \ -d '{ "ciphertext": "knoxcall:v1:G2x..." }' # → { "ciphertext": "knoxcall:v2:K9p...", "key_version": 2 } ``` `rewrap` decrypts the old ciphertext with the retired version, re-encrypts with the active version, and returns the new ciphertext — **the plaintext never crosses the wire**. The operation runs entirely on KnoxCall's side. The typical migration loop: ```text theme={"dark"} for each row in your_table where ciphertext_version < current_version: new_ct = POST /admin/crypto/keys//rewrap { ciphertext: row.ct } UPDATE your_table SET ct = new_ct WHERE id = row.id ``` After every row is rewrapped, you can safely destroy the old version. ## Destroying a version (cryptographic erasure) ```bash theme={"dark"} curl -X DELETE https://api.knoxcall.com/admin/crypto/keys/customer-pii/versions/1 \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "X-Tenant-ID: $TENANT_ID" ``` What this does: 1. Refuses with `409 deletion_not_allowed` if `deletion_allowed = false` on the parent key (the safety latch — see below for how to raise it). 2. Refuses with `409 active_version` if the version is `active` — rotate first. 3. Wipes the wrapped key bytes from the row, flips status to `destroyed`, records who and when. Any ciphertext that named this version in its prefix is **gone forever**. There's no off-site backup. This is the feature, not a bug — it's the right-to-be-forgotten primitive. ## `deletion_allowed` Every key ships with `deletion_allowed = false`. Flipping it to `true` is a deliberate, audit-logged action that says "I'm about to destroy a version on purpose." Flip it from the API: ```bash theme={"dark"} curl -X PATCH https://api.knoxcall.com/v1/crypto/keys/customer-pii \ -H "Authorization: Bearer $KC_TOKEN" \ -H "Content-Type: application/json" \ -d '{"deletion_allowed": true}' ``` …or from the admin UI: **Crypto Keys → your key → Versions → Allow version destruction**. The UI toggle requires a recent second-factor verification; the `/v1` call is bounded by the credential's `transit:manage` grant instead, because there is no human on the machine plane to re-authenticate. Pattern we recommend: 1. Rewrap all data to the new active version. 2. Confirm in a query that no row in your DB still has ciphertext at the version you're about to destroy. 3. `PATCH /v1/crypto/keys/` with `{"deletion_allowed": true}`. 4. `DELETE /versions/`. 5. `PATCH` it back to `{"deletion_allowed": false}`. Every flip writes a `transit.key.deletion_allowed` audit row recording the previous and new value, so "who armed this key, and when" is answerable after the fact. Before 2026-09, this latch had no writer on any surface: it was `false` for every key, the admin UI's Destroy button was therefore never shown, and a `DELETE` of any version answered `500`. Both are fixed — the refusal is now a `409` with a `deletion_not_allowed` code, and the latch is settable on both surfaces as above. ## What rotation doesn't do * It doesn't break existing decryption — old ciphertext keeps working until you destroy the old version. * It doesn't re-encrypt existing data — that's `rewrap`. * It doesn't change the ciphertext format — same prefix, just a higher version number. * It doesn't propagate to bundled-mode agents instantly — they pick up the new version on their next session-bundle refresh. ## Key cache behaviour Unwrapped key versions are cached in memory for **5 minutes** after first use. Rotation invalidates the previous active entry; destroy invalidates the destroyed entry. You don't need to worry about the cache day-to-day — it's an internal performance optimisation, not something you manage. ## Audit log markers | Audit action | Means | | - | - | | `transit.key.create` | A new key was minted (version 1 active) | | `transit.key.rotate` | New version became active | | `transit.encrypt` / `transit.decrypt` | Single op (records key version, not plaintext) | | `transit.rewrap` | Ciphertext was rewrapped from one version to another | | `transit.key.destroy_version` | Version was cryptographically erased | ## See also * [Crypto Keys overview →](/essentials/crypto-keys/crypto-keys-overview) * [Tenant master key →](/essentials/key-management/tenant-master-key) — the wrap chain that protects every transit key # Authentication modes Source: https://docs.knoxcall.com/essentials/dyn-db-credentials/auth-modes Pick how KnoxCall reaches your database: direct, agent tunnel, SSH tunnel, or AWS RDS IAM. # Authentication modes When you register a database, you pick one of four **execution modes** that decides how KnoxCall actually talks to it. The right pick depends on where the database lives and how you want credentials to flow. ## Quick decision guide ```text theme={"dark"} Is the DB publicly reachable on the internet? ├─ Yes → "Direct" └─ No ├─ Is it AWS RDS / Aurora with IAM auth enabled? │ └─ Yes → "IAM" (no admin password needed, 15-min tokens) ├─ Do you have a KnoxCall agent running in the same VPC/network? │ └─ Yes → "Agent tunnel" (DSN never leaves your network) └─ Do you have an SSH host that can reach the DB? └─ Yes → "SSH tunnel" ``` ## Direct KnoxCall opens a TCP connection straight from the control plane to your database, authenticates with the admin credential you registered, and runs `CREATE USER` / `DROP USER` SQL against the DB. **Use it when:** * Your DB is publicly reachable (or KnoxCall's IP is allow-listed in your DB firewall). * TLS is already in place (default `sslmode=require`). * You're OK with the admin credential leaving the customer network on each issue/revoke. **Required fields:** host, port, admin username, admin password. ## Agent tunnel You install a small KnoxCall **agent** inside your VPC. The agent maintains an outbound WebSocket to the control plane. When credentials need minting, KnoxCall pushes a `db_run_sql` command through that WebSocket; the agent runs the SQL **inside your network** and reports back. **The DSN and admin password are pushed just-in-time on each mint** — they don't sit on the agent. **Use it when:** * Your DB isn't publicly reachable and you don't want to open an inbound firewall hole. * You want the strongest "DSN never leaves my VPC" story (this is the structural advantage over self-hosted Vault, which has to reach to the database). * You already run a KnoxCall agent for other features. **Required fields:** host, port, admin username, admin password, agent\_id. `agent_id` must name an agent registered to your tenant **in the same environment** as the key or session creating the connection. A Test-mode caller cannot bind a connection to a Live agent, and vice versa — the connection is rejected with `400 validation_error`, and a mint against a connection whose agent lives in the other environment is refused at dispatch. Test credentials are the ones you hand to CI and to contractors; they do not reach the agent running inside your production network. ## SSH tunnel KnoxCall opens an SSH connection to a customer-supplied SSH host (a jump server, bastion, or any reachable Linux box that can see the DB), uses `forwardOut` to tunnel a TCP connection to your DB, and then runs SQL through that tunnel. The SSH private key is encrypted with your tenant master key and stored alongside the connection. The matching public key lives on the SSH host in `~/.ssh/authorized_keys` — KnoxCall is the **client**, just like any developer's laptop. **Use it when:** * Your DB isn't publicly reachable, you don't (yet) run a KnoxCall agent, but you have a bastion or jump host. * You'd rather manage `authorized_keys` than another moving part. **Required fields:** SSH host, SSH username, SSH private key (PEM), DB host (resolved from inside the SSH host's network), admin username, admin password. Optional: SSH port (default 22), key passphrase. **Engine support:** Postgres, MySQL, and MongoDB. ## IAM (AWS RDS / Aurora) For RDS Postgres or RDS / Aurora MySQL clusters with IAM database authentication enabled. KnoxCall uses the AWS RDS Signer SDK to mint a 15-minute auth token for an existing IAM-enabled DB user — **no admin password is stored, no dynamic CREATE USER runs**. The DB user is one you've already provisioned and granted IAM auth on: ```sql theme={"dark"} -- Postgres GRANT rds_iam TO app_user; ``` ```sql theme={"dark"} -- MySQL (Aurora / RDS) CREATE USER 'app_user' IDENTIFIED WITH AWSAuthenticationPlugin AS 'RDS'; ``` KnoxCall's control-plane needs an AWS role with `rds-db:connect` on the cluster's user ARN. **Use it when:** * You're on RDS and want to skip storing admin passwords entirely. * Your application already supports IAM auth. **Required fields:** RDS endpoint host, AWS region, IAM-enabled DB user. No admin password, no port (defaults to engine default). **Lease ceiling:** AWS pins auth tokens to 15 minutes regardless of the lease TTL you ask for. KnoxCall caps the lease at that ceiling. ## Comparison | | Direct | Agent tunnel | SSH tunnel | IAM | | - | - | - | - | - | | **DB needs public address?** | Yes | No | No (only the SSH host does) | Yes (RDS endpoint) | | **Admin password stored?** | ✅ encrypted | ✅ encrypted | ✅ encrypted | ❌ none | | **Extra infra to run?** | None | KnoxCall agent | An SSH host you control | An AWS IAM role | | **DSN leaves your network?** | Yes (during mint) | No | Yes (over SSH-encrypted tunnel) | No (token signed in AWS) | | **Engines** | pg / mysql / mongo | pg / mysql / mongo | pg / mysql / mongo | postgres / mysql | | **Lease TTL ceiling** | Customer-set | Customer-set | Customer-set | 15 min (AWS-pinned) | ## Testing before you save Every New Database page has a **Test** button that runs the smallest possible probe for the selected mode: * Direct / SSH tunnel — opens a real connection and runs `SELECT 1` (or `ping` for Mongo). * Agent tunnel — dispatches `SELECT 1` through the agent's control-WS. * IAM — calls RDS Signer once and confirms a token was minted. Create is gated on a successful test. Any field change after a successful test invalidates it — you have to re-test before saving. # Lease lifetime Source: https://docs.knoxcall.com/essentials/dyn-db-credentials/leases Every credential KnoxCall mints is tracked as a lease that auto-expires. Learn how TTLs are resolved, capped, and revoked. # Lease lifetime Every database credential KnoxCall mints is tracked as a **lease**. The lease records who got the credential, what role it was minted from, when it was issued, and — most importantly — **when it expires**. When the lease expires, KnoxCall automatically runs the role's `revocation_sql` against the database and drops the user. The customer doesn't have to remember to clean up. ## Two TTL fields Each connection (and each role) has two TTL settings: * **Default TTL** — the duration applied when the caller doesn't ask for a specific TTL. Most "give me a credential" calls don't specify, so this is the typical case. * **Max TTL** — the absolute ceiling. A caller can ask for less, but never more. ```text theme={"dark"} default_ttl_seconds: 3600 # 1 hour — the default max_ttl_seconds: 86400 # 24 hours — the ceiling ``` A request like `POST /v1/dyn-db-credentials/{name}/creds/{role}` with no `ttl_seconds` mints a 1-hour credential. A request asking for `ttl_seconds=43200` (12h) mints a 12-hour credential. A request asking for `ttl_seconds=172800` (48h) is silently clamped to the 24h ceiling — it doesn't fail. ## Resolution order When a credential is minted, the TTL is resolved in this order: 1. **Caller-requested** TTL (if provided and > 0). 2. **Role-level** default (overrides connection default, if set). 3. **Connection-level** default (the fallback). Then the result is clamped to `min(role.max_ttl, connection.max_ttl)`. So if you have `connection.max_ttl=86400` and `role.max_ttl=7200`, the role-level cap wins — that role can never mint a credential longer than 2 hours, no matter what the caller asks for. This lets you define a strict role for prod (`max_ttl=900` — 15 minutes) sitting on a connection with a more generous default (`max_ttl=86400`) used by other roles. ## How revoke works There are two paths to credential revocation: ### 1. Automatic (on expiry) A background **lease worker** scans for expired leases on a regular tick. When it finds an expired lease, it calls the executor's `revoke()` — which runs the role's `revocation_sql` template substituted with the username — and flips the lease's status to `expired`. You can see expired leases in the audit log: action `db_credential.revoke` with `executor_ok: true`. ### 2. Explicit (manual) `POST /v1/dyn-db-credentials/leases/{lease_id}/revoke` (or click **Revoke** in the admin UI) does the same thing on demand. The lease ends up in `revoked` status rather than `expired` — useful for distinguishing "this was killed because someone leaked it" from "this aged out normally." ## IAM mode is special For [IAM mode](/essentials/dyn-db-credentials/auth-modes#iam-aws-rds-aurora), KnoxCall doesn't create a dynamic DB user — it mints a 15-minute AWS auth token for an existing IAM-enabled user. There's no `DROP USER` to run on revoke; the token simply stops working. Implications: * **Lease TTL is capped at 15 minutes** regardless of your `default_ttl_seconds` / `max_ttl_seconds` settings. * The lease worker still flips state at expiry for audit-trail completeness, but `executor.revoke()` is a no-op. * Explicit revoke also flips state but doesn't actively kill the token — you can't kill an AWS-signed token mid-flight; you wait for it to expire. If you need fast kill-switch behaviour on RDS, layer on a security-group revoke or AWS IAM policy detach. KnoxCall's lease state isn't a substitute for those. ## Recommended values | Use case | Default TTL | Max TTL | | - | - | - | | Engineer ad-hoc query | 1h (`3600`) | 8h (`28800`) | | CI / scheduled job | 15m (`900`) | 1h (`3600`) | | Long-running migration | 4h (`14400`) | 8h (`28800`) | | Contractor offboarding | duration = engagement | same | | High-trust prod read-write | 5m (`300`) | 15m (`900`) | Shorter is always safer. The friction cost of re-issuing is one API call. ## Reading lease state in the UI Open a connection on the **Dynamic DB Credentials** page to see its **Live leases** panel. Each row shows: * The minted username (also the lease's subject ID — useful for grepping audit logs). * The role it came from. * When it expires. * A **Revoke** button. The list shows every **live** lease — one whose database user still exists on your server. That means `active`, `renewing` and `errored`. Once a lease moves to `expired` or `revoked` its user has been dropped, so it falls off this view but remains in audit history. `errored` is on the list deliberately, and it is the row to look at first: KnoxCall gave up renewing that lease after five consecutive failures, so nothing is refreshing or expiring it and **the database user is probably still live**. Revoke it to drop the user. It is also the reason a delete can be refused — a connection or role with a live lease returns `409` (see below), and `errored` counts. ## Listing leases over the API `GET /v1/dyn-db-credentials/leases` returns the same set the UI panel shows — **live leases only**, meaning `status` `active`, `renewing` or `errored`. A lease that has `expired` or been `revoked` no longer has a database user, so it is neither listed nor counted in `total`; its history stays in the audit log. Each row names the database it belongs to: `connection_name` and `engine` come from the connection, `role_name` from the role it was minted from, and `username` is the account on the customer's server. ```bash theme={"dark"} curl -H "Authorization: Bearer $TOKEN" "https://api.knoxcall.com/v1/dyn-db-credentials/leases?connection=postgres-prod&limit=50" ``` `connection` is an exact match on the connection's name and is scoped to the calling key's Live/Test space — a `tk_test_` key never sees a Live lease, and never sees one by naming a Live connection. Pagination is `limit`/`offset`, and both, along with `total`, are returned **inside** `data`. ## "has N active credential lease(s)" — deleting a connection or role Deleting a connection or a role is refused while any of its leases is live: ```json theme={"dark"} { "error": { "code": "conflict", "message": "connection 'postgres-prod' has 2 active credential lease(s); revoke them before deleting so their database users can be dropped" } } ``` This is on purpose. Revoking a credential means `DROP USER` on *your* database, and reaching it needs the connection's host and admin credential — or, for a role, its revocation SQL template. Delete those first and the leftover database users are stranded with nothing left that can drop them. **The N in that message is exactly the set this page lists.** It is the same definition of live — `active`, `renewing`, `errored` — used by the delete check, the UI panel and `GET /v1/dyn-db-credentials/leases`, so anything blocking your delete is visible with a Revoke button next to it. Revoke them, then retry the delete. # Dynamic DB Credentials overview Source: https://docs.knoxcall.com/essentials/dyn-db-credentials/overview Mint short-lived database credentials on demand. Replaces long-lived service-account passwords with auth that auto-expires. > **Renamed 2026-05-03.** This feature was previously called **Databases**. > The old `/v1/databases/*` and `/admin/databases/*` paths now return 404 — > use `/v1/dyn-db-credentials/*` and `/admin/dyn-db-credentials/*`. > The "Databases" name has been freed for the upcoming **Database Proxy** > product (a TCP-level Postgres / MySQL wire-protocol proxy). # Dynamic DB Credentials overview KnoxCall's **Dynamic DB Credentials** feature lets you mint short-lived database credentials on demand — for engineers, CI pipelines, scheduled jobs, or any workload that needs temporary DB access without sharing a long-lived password. You register a database connection once. After that, callers ask KnoxCall for a credential whenever they need one. KnoxCall mints a fresh username + password (or an AWS auth token), tracks it as a **lease**, and automatically revokes it at expiry. ## Why use it | Problem | Without KnoxCall | With KnoxCall | | - | - | - | | Engineers share a Slack-pinned `prod_password` | Anyone who joins/leaves the team triggers a rotation | Each engineer mints their own 1-hour credential | | CI pipeline holds a service-account secret | Leak = full DB compromise until rotation | Leak = ≤ TTL until auto-revoke | | Audit trail of "who ran what against prod" | Best-effort, by IP | Per-credential, per-lease, in audit log | | Onboarding a contractor for 2 weeks | Manual create + reminder to delete | Set TTL = 14 days, drops itself | ## How it works 1. **Register a connection** — point KnoxCall at your database (Postgres, MySQL, or MongoDB). Pick one of four [authentication modes](/essentials/dyn-db-credentials/auth-modes). 2. **Define a role** — an SQL template that describes the kind of user to mint (read-only, read-write, or your own template). Built-in `readonly` / `readwrite` templates work for most setups. 3. **Mint a credential** — call `POST /v1/dyn-db-credentials/{name}/creds/{role}` with an OAuth access token (or legacy API key — see [Authentication](/api-reference/authentication)). Get back a username + password + expiry. 4. **Use it** — connect with the returned credentials like any other DB user. 5. **Forget it** — at the [lease's expiry](/essentials/dyn-db-credentials/leases), KnoxCall drops the user automatically. Or revoke it explicitly with `POST /v1/dyn-db-credentials/leases/{id}/revoke`. ## Quick start (UI) 1. Go to **Dynamic DB Credentials** in the admin UI and click **New Connection**. 2. Fill in the connection details. **Test** the connection — KnoxCall verifies it can reach your DB before letting you save. 3. After creation, expand the connection and click **Register role** → pick `readonly` or `readwrite`. 4. Click **Mint credential** to issue your first short-lived user. ## Quick start (API) All responses use the standard `{ data: ..., meta: { request_id: "..." } }` envelope. The examples below show the `data` fields. ```bash theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) # 1. Register a connection curl -X POST https://api.knoxcall.com/v1/dyn-db-credentials \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "postgres-prod", "engine": "postgres", "host": "db.example.internal", "port": 5432, "admin_username": "kc_admin", "admin_password": "...", "execution_mode": "direct", "default_ttl_seconds": 3600, "max_ttl_seconds": 86400 }' # 2. Register a role (built-in readonly template) curl -X POST https://api.knoxcall.com/v1/dyn-db-credentials/postgres-prod/roles \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "analytics_ro", "template": "readonly" }' # 3. Mint a credential curl -X POST https://api.knoxcall.com/v1/dyn-db-credentials/postgres-prod/creds/analytics_ro \ -H "Authorization: Bearer $TOKEN" # → data: { "username": "kc_user_...", "password": "...", "expires_at": "...", # "lease_id": 42, "connection_name": "postgres-prod", "role_name": "analytics_ro" } ``` ## Supported engines | Engine | API value | Direct | Agent tunnel | SSH tunnel | IAM (RDS) | | - | - | - | - | - | - | | PostgreSQL | `postgres` | ✅ | ✅ | ✅ | ✅ | | MySQL | `mysql` | ✅ | ✅ | ✅ | ✅ | | MongoDB | `mongo` | ✅ | ✅ | ✅ | ❌ (not RDS) | See [authentication modes](/essentials/dyn-db-credentials/auth-modes) for what each mode means and when to pick it. ## Built-in role templates | Template | Postgres | MySQL | MongoDB | | - | - | - | - | | `readonly` | `GRANT SELECT ON ALL TABLES` | `GRANT SELECT` | `read` role | | `readwrite` | `GRANT SELECT, INSERT, UPDATE, DELETE` | `GRANT SELECT, INSERT, UPDATE, DELETE` | `readWrite` role | Both templates create a user with the password KnoxCall generates and grant just enough permissions for the named scope. If neither fits, supply your own `creation_sql` + `revocation_sql`. ## Who can issue a credential Issuing a credential from the dashboard creates a live username and password on your database, so it is a reveal. In the dashboard this is a **Class B reveal**: only an **Owner** or an **Admin** can do it (an Editor cannot, even with **Reveal secrets** turned on; that setting covers secret values, certificates and credentialed workflow tests only), and every use asks for a fresh second factor (an authenticator code, a passkey or a security key; an emailed code does not count). Each use writes a `plaintext.reveal` entry to the audit log **before** anything is shown, and counts against the same per-person reveal budget as showing a secret's value (60 per 5 minutes). If the audit entry cannot be written, or the budget cannot be counted, the request is refused rather than served. On the Management API (`/v1`) a credential still needs the matching permission, and a token issued to a person never carries `dyn_db_cred:mint`. Registering connections and roles and revoking leases are unchanged. ## Next steps * [Authentication modes →](/essentials/dyn-db-credentials/auth-modes) * [Lease lifetime →](/essentials/dyn-db-credentials/leases) * [API reference →](/api-reference/overview) # Browser encryption & UI Components Source: https://docs.knoxcall.com/essentials/encryption/browser Seal sensitive values in the browser so plaintext never reaches your servers. Client-side encryption, single-use reveal tokens, and iframe-isolated components. # Browser encryption & UI Components Encrypt sensitive values **in the page**, against your tenant's public key, so plaintext (an SSN, a bank account, a national ID) never touches your servers. You store and transmit only a portable [`kc:` ciphertext](/essentials/encryption/ciphertext-spec) — and only KnoxCall, holding the private key, can decrypt it. No API key is ever shipped to the browser. **This is not a payment-card control.** Sealing happens in *your* page's JavaScript, so the plaintext is in a context your own site controls — that is the opposite of what a hosted-fields component does, and it reduces no cardholder-data scope. KnoxCall also holds no PCI DSS attestation. Use it for PII and secrets; use your processor's hosted fields for cards. ## 1. Client-side encryption (`@knoxcall/browser`) Your backend fetches a **sealing bundle** (the tenant public key + a key reference — no private material) and hands it to the page. Encryption is then a pure client-side operation with no network round-trip. ```ts theme={"dark"} // --- your backend (holds the KnoxCall credential) --- // GET /v1/encrypt/sealing-bundle?key=payments -> { public_key_raw, key_ref } import { KnoxCall } from '@knoxcall/sdk'; const knox = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET app.get('/checkout/sealing-bundle', async (req, res) => { res.json(await knox.crypto.getSealingBundle({ key: 'payments' })); }); ``` ```ts theme={"dark"} // --- the browser --- import { KnoxEncryptor } from '@knoxcall/browser'; const bundle = await fetch('/checkout/sealing-bundle').then((r) => r.json()); const enc = new KnoxEncryptor(bundle, { purpose: 'pci' }); const ciphertext = await enc.encrypt(cardNumber); // "kc:1:s:…:$" // send `ciphertext` to your backend / KnoxCall — the PAN never left the page. ``` The scheme (ephemeral ECDH P-256 → HKDF-SHA256 → AES-256-GCM) is byte-identical to the server, so any `kc:` value you produce here decrypts through `/v1/decrypt`, a [Relay route-action](/essentials/routes/what-are-routes), or a data-plane enclave. ## 2. Reveal to the browser without an API key To show a user one decrypted value, your backend mints a **single-use, payload-pinned capability token** (see [client-side reveal](/essentials/encryption/overview#client-side-reveal-without-an-api-key)) and the page exchanges it once: ```ts theme={"dark"} import { KnoxClient } from '@knoxcall/browser'; const knox = new KnoxClient(); const pan = await knox.reveal(capabilityToken, ciphertext); // one-time // or, for a vault token: const value = await knox.detokenize(capabilityToken, 'tok_…'); ``` The token is consumed server-side on first use, expires in minutes, and only matches the exact value it was minted for. ## 3. Iframe UI Components (`@knoxcall/react`) For the strongest isolation, capture and display data inside a **cross-origin iframe** served from a KnoxCall origin. The host page — and any XSS on it — never sees the raw PAN; your callback receives only the `kc:` ciphertext plus non-sensitive display bits (last4/brand). ```tsx theme={"dark"} import { CardCollect } from '@knoxcall/react'; setReady(complete)} onToken={(ciphertext, { last4, brand }) => { // store the ciphertext; show `•••• last4` to the user. }} /> ``` Every message from the iframe is **origin-checked (exact match), source-checked (must be the KnoxCall iframe), and shape-validated** before your app trusts it — that logic lives in the shared, unit-tested protocol. `` is the mirror: it decrypts (or detokenizes) with a capability token and displays the value *inside* the iframe, so plaintext is shown to the user but never crosses into your JavaScript. ## Which to use | You need | Use | | - | - | | Seal a value your own form already holds | `KnoxEncryptor.encrypt()` | | Never let your JS touch the PAN at all | `` (iframe) | | Show one decrypted value to a user | capability token + `KnoxClient.reveal()` or `` | | Decrypt server-side / in transit | [`/v1/decrypt`](/essentials/encryption/overview) or [Relay route-actions](/essentials/routes/what-are-routes) | # kc: ciphertext format & cryptographic spec Source: https://docs.knoxcall.com/essentials/encryption/ciphertext-spec The exact wire format and cryptographic construction behind KnoxCall's portable kc: ciphertext — ECDH P-256, HKDF-SHA256 with context binding, and AES-256-GCM. Published in full so your security reviewers can audit it. # kc: ciphertext format & cryptographic spec We publish the exact construction of the `kc:` ciphertext — algorithms, parameters, and key hierarchy — so that a security reviewer can evaluate it without a sales call. Specificity is the point: you should be able to verify what we built, not take our word for it. ## Wire format A `kc:` value is a single ASCII string. Each variable segment is **base64url** (no padding): ``` kc:::::::$ ``` | Segment | Meaning | | - | - | | `version` | Scheme version (integer). Drives crypto-agility — the construction can change under a new version without breaking stored data. Currently `1`. | | `datatype` | One of `s` (string), `n` (number), `b` (boolean), `j` (JSON), `x` (raw bytes). Lets decrypt round-trip the original type. | | `key_ref` | `"::"` — names the recipient keypair so decrypt needs **zero** out-of-band metadata. | | `eph_pubkey` | The ephemeral ECDH public key as a 65-byte uncompressed P-256 point (`0x04 ‖ X ‖ Y`). | | `iv` | 12-byte AES-GCM nonce. | | `ciphertext‖tag` | AES-256-GCM output with the 16-byte authentication tag appended. | | `$` | Terminator — guards against trailing-data and makes the string recognisable by eye and by tooling. | The leading `kc:` and trailing `:$` make detection cheap and unambiguous (`POST /v1/decrypt` passes non-`kc:` values through untouched on this basis). ## Cryptographic construction (version 1) Per-value **ECIES** on the NIST P-256 curve: 1. Generate an **ephemeral P-256 keypair** for this value. 2. Compute the ECDH shared secret `Z` between the ephemeral private key and the recipient (tenant app) public key. 3. Derive the content-encryption key: ``` CEK = HKDF-SHA256(ikm = Z, salt = eph_pubkey, info = AAD, length = 32 bytes) ``` 4. Encrypt with **AES-256-GCM** (12-byte random IV, 16-byte tag). The `kc:` header (version, datatype, key\_ref, eph\_pubkey, iv) is bound as the GCM **additional authenticated data**, so no header segment can be swapped without failing the tag. ### Context binding (the AAD / HKDF `info`) The HKDF `info` is a length-prefixed, canonical concatenation of a domain-separation label and the full context: ``` info = len‖"KnoxCall-kc1-ECIES-P256-AES256GCM" ‖ len‖tenant_id ‖ len‖app_key_id ‖ key_version(uint32 BE) ‖ len‖datatype ‖ len‖purpose ``` `purpose` is the optional **data-role** (`pci`, `eu`, …). Because the full context is folded into key derivation, a ciphertext is cryptographically pinned to the tenant, key, version, datatype, and data-role it was created under — decrypting under any different context simply fails the GCM tag. **How this differs from raw-ECDH designs.** Some implementations feed the raw ECDH shared secret straight into AES with no KDF. KnoxCall **always** runs HKDF-SHA256 and binds the context into the `info` parameter. This gives domain separation, defends against cross-context reuse, and means a stolen ciphertext cannot be replayed under a different tenant, key, or role. ## Key hierarchy & custody ``` platform master key (env MASTER_KEY_B64, or unsealed from AWS/GCP/Azure KMS) │ wraps ▼ tenant master key (per tenant; or wrapped by YOUR KMS under BYOK) │ wraps ▼ app ecdh-p256 private key (PKCS#8 DER, stored only as a wrapped KCT1 envelope) │ decrypts ▼ kc: ciphertext ``` * The app private key is **never stored in the clear** — only as a `KCT1` envelope wrapped under the tenant master key. The public half is cached for encryption. * Under [BYOK](/essentials/tenant-kms/overview), the tenant master key is wrapped by **your** cloud KMS, so KnoxCall cannot unwrap it without a call your KMS policy authorises — and you can revoke that at any time. * **Cryptographic erasure:** destroy a key version and every `kc:` value produced under it becomes permanently unreadable. ## Versioning & rotation Every `kc:` value names its `key_version` in the `key_ref`. Rotating an `ecdh-p256` key mints a new version: new values encrypt under the new version while existing values still decrypt under theirs. No bulk re-encryption is required, and a long-running re-encrypt can pin a target version to stay consistent across a concurrent rotation. ## Parameter summary | Parameter | Value | | - | - | | Curve | NIST P-256 (`prime256v1`), ephemeral per value | | KDF | HKDF-SHA256, 32-byte output, salt = ephemeral public key, context-bound `info` | | AEAD | AES-256-GCM, 12-byte IV, 16-byte tag | | Header integrity | `kc:` header bound as GCM AAD | | Key wrapping | KCT1 envelope (AES-256-GCM) under the per-tenant master key | | Master key custody | env, or AWS KMS / GCP KMS / Azure Key Vault unseal; per-tenant BYOK | ## Migrating from Evervault `ev:` KnoxCall cannot decrypt an Evervault `ev:` ciphertext (only your Evervault app holds that key). Migration is a one-way re-encryption you run with `scripts/migrate-ev-to-kc.ts`: decrypt each `ev:` value via your Evervault app, then re-encrypt via `POST /v1/encrypt` to get a `kc:` value. # Portable Encryption overview Source: https://docs.knoxcall.com/essentials/encryption/overview Encrypt any JSON, get back a self-describing kc: ciphertext you can store anywhere. Public-key encryption so plaintext can be sealed before it ever reaches KnoxCall, plus single-use client-side reveal tokens. # Portable Encryption overview KnoxCall's **Portable Encryption** turns any value into a single, self-describing **`kc:` ciphertext string** you can drop into a database column, a log line, or a message queue with no schema change — and which carries everything needed to decrypt it *except* the private key. It is public-key encryption: you encrypt against a tenant **public key**, and only KnoxCall (holding the matching private key, wrapped under your [tenant master key](/essentials/key-management/tenant-master-key)) can decrypt. That means plaintext can be sealed **before it ever reaches KnoxCall** — including in a browser. Use it for: * **"Encrypt anything, store it anywhere"** — PII, PHI, PAN, API responses, free-form blobs. The ciphertext is portable across systems. * **Structure-preserving encryption** — send a whole JSON object; get the same shape back with every scalar leaf replaced by a `kc:` string. * **Frontend scope reduction** — seal card/PII data in the page so it never touches your servers (paired with the browser SDK). * **Crypto-agility** — every `kc:` value carries its scheme version, so the format can evolve without breaking stored data. ## Quick example ```bash theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) # Encrypt — structure-preserving. Scalar leaves become kc: strings. curl -X POST https://api.knoxcall.com/v1/encrypt \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "data": { "card": "4242424242424242", "amount": 1299 } }' # → { "data": { "card": "kc:1:s:...:$", "amount": "kc:1:n:...:$" }, "key": "kc-default", "key_version": 1 } # Decrypt — non-kc values pass through untouched. curl -X POST https://api.knoxcall.com/v1/decrypt \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "data": { "card": "kc:1:s:...:$", "amount": "kc:1:n:...:$" } }' # → { "data": { "card": "4242424242424242", "amount": 1299 } } # Inspect — metadata about a ciphertext WITHOUT decrypting it. curl -X POST https://api.knoxcall.com/v1/inspect \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "value": "kc:1:s:...:$" }' # → { "encrypted": true, "scheme": "kc", "version": 1, "datatype": "s", "key_ref": {...}, "fingerprint": "..." } ``` If you don't name a `key`, KnoxCall auto-provisions a default `ecdh-p256` key on first use — the happy path is zero-config. To pin a key, pass `"key": "my-key"` (create it with `key_type: "ecdh-p256"` via [Crypto Keys](/essentials/crypto-keys/crypto-keys-overview)). ## Data roles Pass an optional `role` on encrypt to bind a **data-role** into the ciphertext (e.g. `pci`, `eu`). The role is folded into the key derivation, so the value can only be decrypted by presenting the **same role** — the policy travels *with the data*, not just at the gateway. ```bash theme={"dark"} curl ... -d '{ "data": "4242...", "role": "pci" }' # encrypt under role "pci" curl ... -d '{ "data": "kc:...:$", "role": "pci" }' # must pass role "pci" to decrypt ``` ## Client-side reveal without an API key Browsers and agents should never hold a KnoxCall credential. To let a frontend reveal **one** value, your backend mints a **single-use, payload-pinned capability token** and hands it over: ```bash theme={"dark"} # Backend (holds the KnoxCall credential): mint a token bound to exactly one ciphertext. curl -X POST https://api.knoxcall.com/v1/client-tokens \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "action": "decrypt", "data": "kc:1:s:...:$", "ttl_seconds": 300 }' # → { "token": "kct_...", "expires_at": "...", "action": "decrypt" } # Browser (no KnoxCall credential): exchange the token ONCE for the plaintext. curl -X POST https://api.knoxcall.com/v1/client/decrypt \ -H "Authorization: Bearer kct_..." -H "Content-Type: application/json" \ -d '{ "data": "kc:1:s:...:$" }' # → { "data": "4242424242424242" } ``` The token is consumed on first use, expires within minutes, and only matches the exact ciphertext it was minted for. A leak reveals at most that one value, once. The same flow works for vault tokens with `action: "detokenize"` → `POST /v1/client/detokenize`. ## Dual-custody vaults A [vault](/essentials/vaults/overview) created with `custody_mode: "dual"` issues `kc:` ciphertext as its token and **stores no value at all** — you keep the ciphertext, KnoxCall keeps only the key. A breach of KnoxCall alone leaks nothing, and detokenize is a pure decrypt (no database lookup). ## Learn more * [Ciphertext format & cryptographic spec](/essentials/encryption/ciphertext-spec) — the exact wire format and algorithms. * [Encryption threat model](/security/threat-model) — what the design defends against, and the trust boundaries. # Collection Environment Defaults Source: https://docs.knoxcall.com/essentials/environments/collection-environment-defaults Define expected environments, base URLs, and default clients at the collection level. New routes auto-provision, existing routes stay in sync, and drift is visible at a glance. # Collection Environment Defaults When all the routes in a collection share the same shape — the same environments, the same target host pattern, the same set of clients — it's tedious (and error-prone) to configure each route individually. **Collection environment defaults** let you declare what a collection *expects* once, and every route in the collection automatically picks it up. ## What You Can Define For each collection, you configure: | Setting | Scope | Description | | - | - | - | | **Expected environments** | Collection | The list of environments routes in this collection should have (e.g. `production`, `development`) | | **Base URL** | Per environment | The default target host used when prefilling new routes | | **Default clients** | Per environment | Clients automatically attached to routes in that environment | | **Header injections** | Per environment | *(Reserved for future use)* | | **Body injections** | Per environment | *(Reserved for future use)* | Expected environments is a **list**, not a single value. A Shopify collection might expect both `production` and `development`, so every route in it is guaranteed to have both configured. *** ## Why Use Collection Defaults? ### Without Defaults Every time you add a route to a collection, you have to remember to: 1. Create a `production` environment config 2. Create a `development` environment config 3. Paste the same base URL everywhere 4. Attach the same four or five clients on both environments 5. Hope nobody forgets a step three weeks from now ### With Defaults * **Declare once** — "This collection has production and development, base URLs are X and Y, and these clients are always attached." * **Auto-provision on join** — When a route joins the collection, missing environments are created silently from the defaults. * **Prefill on create** — The route creation form fills in the base URL from the collection. * **Drift detection** — Routes that drift from the defaults are flagged on both the collection page (sync stats) and the route page (clients compare row). * **One-click reset** — Bring a route back to the canonical client list with a single click. *** ## Setting Up Defaults ### 1. Open the Collection's Environments Tab Navigate to **Collections → *your collection* → Environments** tab. ### 2. Declare Expected Environments At the top of the tab, click any environment chip to add or remove it from the expected list. ```text theme={"dark"} Expected environments: [✓ production] [✓ development] [ staging ] ``` Removing an environment from the expected list only stops auto-provisioning going forward. Existing route environment configs are **preserved**, not deleted. If any routes already use that environment, you'll be warned before the change is applied. ### 3. Configure Each Environment Pick an environment from the dropdown and fill in: * **Base URL** — e.g. `https://api.shopify.com` for production, `https://sandbox.shopify.com` for development * **Default clients** — Add the clients that should automatically be attached to every route in this collection+environment ### 4. Save Unsaved changes are shown in a sticky save bar at the bottom of the tab. Click **Save** to persist. Saving only updates the collection's defaults — it does **not** automatically apply them to existing routes. Use **Apply to routes** to push changes out. *** ## Auto-Provision on Join When a route joins a collection (either at creation time or by changing its collection later), KnoxCall silently creates any missing environment configs from the collection defaults. ```text theme={"dark"} Route joins "Shopify APIs" collection └─ Collection expects: [production, development] └─ Route currently has: [production] Result: development is auto-created with: • Base URL from collection • Default clients from collection • inject_headers / inject_body from collection (when configured) ``` This happens silently — no modal, no confirmation. Routes always have every environment the collection expects. Auto-provisioning respects tenant boundaries: environments that don't exist for your tenant are skipped. *** ## Base URL Prefill on Route Creation When creating a new route, selecting a collection triggers a preview panel showing the environments that will be provisioned. At the same time, the target URL input is prefilled with the first expected environment's base URL. A small hint appears below the input: > *Prefilled from collection (production) — click to edit* As soon as you start typing, the prefill is locked out — KnoxCall will never overwrite a value you've explicitly edited. *** ## Keeping Routes in Sync Defaults change over time. When they do, KnoxCall helps you bring existing routes back in line. ### The Sync Stats Row On the collection's Environments tab, below the default-clients editor, a sync row shows: ```text theme={"dark"} 12 routes • 8 synced • 3 out of sync • 1 missing env [ Apply to routes ] ``` * **Synced** — The route's client list matches the defaults exactly. * **Out of sync** — The route is missing some defaults, has extra clients, or both. * **Missing env** — The route has no config for this environment yet. ### Apply to Routes Modal Clicking **Apply to routes** opens a modal that shows every route in the collection with its individual diff: * **+ ClientName (will be added)** — Missing defaults that will be attached. * **− ClientName (will be removed)** — Extras that will be detached *(replace mode only)*. * **(keeping: ClientName)** — Extras that will be preserved *(merge mode)*. You choose the **apply mode**: Add missing defaults, preserve any extra clients already on the route. Safe for routes that legitimately have extra clients. Hard sync: add defaults, remove anything not in the defaults. Use when the defaults must be authoritative. Routes that are out-of-sync or missing-env are **preselected** by default, so one click catches you up on everything. *** ## The Route-Level Compare Row Open any route in a collection that has defaults. On the **Clients** tab, the client compare widget adds a **`{collection} default`** row (for example, `Shopify APIs default`) showing the canonical client list for the current environment. When the route's clients differ from — or are missing — those defaults, the row exposes a single **Apply** action. ### Out of Sync The compare row shows the collection's desired client list alongside the route's current one. The Apply action is titled: ```text theme={"dark"} Reset production to Shopify APIs defaults ``` Triggering it performs a **hard replace** — extra clients are removed, defaults are added exactly. This is the intentional, explicit way to snap a single route back to canonical. ### Missing Env When the route has no config for an environment the collection expects, the compare row's Apply action is titled instead: ```text theme={"dark"} Provision development from Shopify APIs defaults ``` Triggering it creates the environment config for this route using the collection's base URL and default clients. The compare row is part of the clients comparison widget — there's no separate banner to dismiss. It simply reflects the current diff and disappears once the route is in sync. *** ## Merge vs Replace: When to Use Each | Situation | Use | Why | | - | - | - | | Routine sync after adding a default client | **Merge (bulk)** | Safe — won't clobber route-specific extras | | Onboarding a fleet of existing routes into a collection | **Merge (bulk)** | Adds missing defaults without surprises | | A single route has drifted and you want it back to canonical | **Reset (compare row)** | Explicit per-route hard sync | | The collection is the single source of truth and extras are forbidden | **Replace (bulk)** | Enforces exact defaults on every route | *** ## Real-World Example **Scenario:** An e-commerce team has a "Shopify APIs" collection with 8 routes. They always run routes in both `production` and `development`, and every route needs the `monitoring-probe`, `office-vpn`, and `ci-runner` clients. ### Step 1: Configure the collection once ```text theme={"dark"} Collection: Shopify APIs Expected environments: [production, development] Production: Base URL: https://api.shopify.com Default clients: monitoring-probe, office-vpn, ci-runner Development: Base URL: https://sandbox.shopify.com Default clients: monitoring-probe, office-vpn, ci-runner, dev-laptop ``` ### Step 2: Add a new route The developer creates `shopify-orders` and picks the Shopify APIs collection. KnoxCall: 1. Shows a preview: *"will pre-configure 2 environments"* 2. Prefills the target URL with `https://api.shopify.com` 3. On save, auto-provisions the `development` environment with its base URL and 4 default clients No manual client-attachment, no copy-paste of URLs. ### Step 3: Add a new default client Three months later, the team adds `newrelic-probe` to the collection defaults and clicks **Apply to routes** with **Merge** mode. All 8 routes receive `newrelic-probe` on both environments — without disturbing any route-specific extras. ### Step 4: A stray route A single route `shopify-legacy-sync` was modified by hand and has `legacy-jenkins` attached. Opening its clients tab shows the compare row out of sync with the `Shopify APIs default` — `newrelic-probe` will be added. The developer triggers the **Reset production to Shopify APIs defaults** action. `legacy-jenkins` is removed, `newrelic-probe` is added, and the route matches canonical. *** ## How It Differs from Collection Variables KnoxCall collections have two related — but separate — systems: | Feature | What It Does | | - | - | | **Collection variables** | Resolves `{{var:name}}` template placeholders in route configs at proxy time | | **Collection environment defaults** *(this page)* | Stamps values onto the route itself when it joins the collection or when you click Apply — the route then owns the values | In other words: defaults are a create-time / apply-time stamp — routes own their values, but can be brought back in sync on demand with Apply/Reset. There is no proxy-time inheritance lookup; every route's runtime config lives on its own per-environment rows. *** ## Audit Trail Every defaults operation is logged to **Audit Logs**: | Action | When | | - | - | | `collection_defaults.apply` | Bulk apply with merge or replace | | `route_environment.reset_to_collection_defaults` | Per-route reset via the compare row Apply action | | `route_environment.provisioned_from_collection` | Per-route provision via the compare row Apply action | | `route_environment.auto_provisioned` | Automatic provision when a route joined a collection | Each entry records the collection, environment, route(s) affected, and the mode used. *** ## Best Practices ### ✅ Do * **Declare expected environments early.** Set them before adding routes so auto-provisioning catches everything. * **Prefer Merge over Replace for bulk apply.** Merge is the safe default; Replace should be a deliberate choice. * **Use Reset (per-route compare row) for drift fixes.** It's unambiguous and audit-logged per route. * **Review the Apply modal's diff before confirming.** The preselection helps, but eyeballing it catches surprises. ### ❌ Don't * **Don't use Replace to "clean up" routes you don't fully understand.** Extras may be there for a reason. * **Don't remove an expected environment to hide drift.** The warning flow lets you remove it, but the underlying route configs stay — the drift just becomes invisible. * **Don't expect defaults to update routes retroactively.** Defaults stamp values at create/apply time; use Apply or Reset to bring routes back in sync with the collection. *** ## Quick Reference | Action | Where | Behavior | | - | - | - | | Declare expected envs | Collection → Environments tab → chip row | Auto-provisions on route join | | Set base URL + default clients | Collection → Environments tab → per-env editor | Saved to the collection only | | Prefill route target URL | Route create page (after picking collection) | Locked out once you type | | Bulk apply to routes | Collection → Environments tab → Apply to routes | Merge or Replace, multi-select | | Single-route reset | Route → Clients tab → compare row Apply | Hard replace | | Single-route provision | Route → Clients tab → compare row Apply (missing env) | Creates env from defaults | *** ## Next Steps Understand the underlying environment system Learn how clients attach to route environments Advanced patterns across environments Review every defaults operation *** **Key Takeaway**: Declare a collection's expected environments, base URLs, and default clients once. New routes auto-provision, existing routes stay in sync via Apply or per-route Reset, and drift never hides for long. # Environment Basics Source: https://docs.knoxcall.com/essentials/environments/environment-basics Understand environments in KnoxCall. Learn how to manage dev, staging, and production configurations with a single route. # Environment Basics **Environments** in KnoxCall allow you to manage different configurations (dev, staging, production) for the same route without duplicating everything. ## What is an Environment? An **environment** is a configuration context that determines which settings to use for a route. Think of it as a profile or mode. ### Real-World Analogy Imagine a light switch with multiple modes: * **"Day" mode** → Bright lights, full power * **"Night" mode** → Dim lights, low power * **"Movie" mode** → Colored lights, ambient Same switch, different settings based on the mode. **Environments work the same way:** * **Production** → Real API keys, production database * **Staging** → Test API keys, staging database * **Development** → Mock data, local server *** ## Why Use Environments? ### The Problem Without Environments Without environments, you'd need **separate routes** for each stage: ```text theme={"dark"} ❌ user-api-dev → https://localhost:3000 ❌ user-api-staging → https://staging.api.com ❌ user-api-prod → https://api.production.com ``` **Issues:** * Duplicate routes to maintain * Easy to misconfigure * Hard to keep in sync * Route names get messy ### The Solution With Environments With environments, you have **one route** with different configs: ```text theme={"dark"} ✅ user-api (base route) ├── production → https://api.production.com ├── staging → https://staging.api.com └── development → https://localhost:3000 ``` **Benefits:** * Single route to maintain * Consistent naming * Easy to switch between environments * Clear separation of configs *** ## How Environments Work ### The Base + Override System KnoxCall uses a **base + override** system: 1. **Base environment** - Default configuration 2. **Override environments** - Customize specific settings **Example:** ```text theme={"dark"} Route: stripe-payments Base (production): Target URL: https://api.stripe.com Header: Authorization: Bearer {{secret:stripe_prod_key}} Clients: [production-server] Override (staging): Target URL: INHERIT from base ✓ Header: Authorization: Bearer {{secret:stripe_staging_key}} ← Override Clients: [staging-server, dev-laptop] ← Override Override (development): Target URL: https://stripe-sandbox.com ← Override Header: INHERIT from base ✓ Clients: [dev-laptop] ← Override ``` **How it works:** * If override = `null` or empty → Use base value * If override = specific value → Use override value * Changes to base automatically affect overrides (unless overridden) * Inheritance is single-level: each override falls back to the **base** (production) config, never to another override. There is no override-to-override chaining. *** ## Default Environments KnoxCall creates one default environment for every tenant: ### Production (Base Environment) * **Name:** `production` * **Type:** Base * **Purpose:** Default configuration * **Usage:** If no environment specified, this is used When you create a route, you're actually configuring the **production environment**. *** ## Common Environment Setup ### Typical 3-Environment Setup Most teams use three environments: #### 1. Development * **Purpose:** Local development and testing * **Target:** `http://localhost:3000` or sandbox APIs * **Secrets:** Test/fake API keys * **Clients:** Developer laptops (dynamic IPs) * **Example:** ``` Name: development Target: https://sandbox.stripe.com Secret: stripe_test_key Clients: dev-laptop-1, dev-laptop-2 ``` #### 2. Staging * **Purpose:** Pre-production testing * **Target:** Staging servers * **Secrets:** Staging API keys (real-ish data) * **Clients:** Staging servers + QA team * **Example:** ``` Name: staging Target: https://staging-api.acme.com Secret: stripe_staging_key Clients: staging-server, qa-team-ips ``` #### 3. Production * **Purpose:** Live customer traffic * **Target:** Production servers * **Secrets:** Production API keys (real data!) * **Clients:** Production servers only * **Example:** ``` Name: production Target: https://api.acme.com Secret: stripe_prod_key Clients: prod-server-1, prod-server-2 ``` *** ## Using Environments in Requests ### Specify Environment with Header To use a specific environment, pass the `environment` option in the [SDK](/sdks/overview): ```typescript theme={"dark"} // Call production (default) const resp = await client.call("user-api", { path: "/api/users" }); // Call staging explicitly await client.call("user-api", { path: "/api/users", environment: "staging" }); // Call development explicitly await client.call("user-api", { path: "/api/users", environment: "development" }); ``` Or, in raw curl, add the `x-knoxcall-environment` header — `$TOKEN` is a minted OAuth access token (see [Authentication](/api-reference/authentication)): ```bash theme={"dark"} # Call production (default) curl https://acme.knoxcall.com/api/users \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: user-api" # Call staging explicitly curl https://acme.knoxcall.com/api/users \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: user-api" \ -H "x-knoxcall-environment: staging" # Call development explicitly curl https://acme.knoxcall.com/api/users \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: user-api" \ -H "x-knoxcall-environment: development" ``` ### No Header = Base Environment If you don't specify `x-knoxcall-environment`, KnoxCall uses the **base environment** (usually `production`). *** ## What Can Be Overridden? You can override almost everything per environment: ### ✅ Can Override | Setting | Example | | - | - | | **Target URL** | `https://api.production.com` vs `https://localhost:3000` | | **Headers** | Different API keys, auth tokens | | **Body** | Different payload structures | | **Secrets** | Production vs test secrets | | **Clients** | Production servers vs dev laptops | | **Rate limits** | Higher limits in prod, lower in dev | | **Method configs** | Different per HTTP method | ### ❌ Cannot Override | Setting | Reason | | - | - | | Route name | Would create confusion | | Enabled/disabled | Applies to all environments | | HTTP methods allowed | Security concern | *** ## Real-World Examples ### Example 1: Stripe API Route **Scenario:** Process payments via Stripe in all environments. **Setup:** ```text theme={"dark"} Route: stripe-payments Methods: POST Production (base): Target: https://api.stripe.com Header: Authorization: Bearer {{secret:stripe_prod_key}} Clients: [prod-payment-server] Staging: Target: INHERIT ✓ Header: Authorization: Bearer {{secret:stripe_test_key}} ← Test key Clients: [staging-server, qa-laptop] Development: Target: INHERIT ✓ Header: Authorization: Bearer {{secret:stripe_test_key}} ← Same test key Clients: [dev-laptop-1, dev-laptop-2, dev-laptop-3] ``` **Result:** * Production charges real cards with prod key * Staging/dev use test keys (no real charges) * Different client authorization per environment ### Example 2: Database API **Scenario:** Query user database across environments. **Setup:** ```text theme={"dark"} Route: user-database Methods: GET, POST Production (base): Target: https://prod-db.acme.com Header: X-API-Key: {{secret:prod_db_key}} Clients: [prod-api-server] Staging: Target: https://staging-db.acme.com ← Different database! Header: X-API-Key: {{secret:staging_db_key}} ← Different key Clients: [staging-api-server] Development: Target: http://localhost:5432 ← Local database Header: X-API-Key: {{secret:dev_db_key}} Clients: [ANY] ← No IP restriction for dev ``` **Result:** * Each environment hits a completely different database * Developers work with local data * Staging has realistic test data * Production has real customer data ### Example 3: Third-Party Webhooks **Scenario:** Receive webhooks from Shopify. **Setup:** ```text theme={"dark"} Route: shopify-webhooks Methods: POST Production (base): Target: https://api.acme.com/webhooks/shopify Header: X-Shopify-Secret: {{secret:shopify_prod_secret}} Clients: [shopify-prod-ips] Staging: Target: https://staging.acme.com/webhooks/shopify Header: X-Shopify-Secret: {{secret:shopify_staging_secret}} Clients: [shopify-staging-ips] Development: Target: https://ngrok-abc123.ngrok.io/webhooks/shopify ← Tunneled local Header: INHERIT ✓ Clients: [shopify-test-ips] ``` **Result:** * Production webhooks go to production backend * Staging webhooks go to staging * Dev webhooks tunnel to developer's localhost via ngrok *** ## NULL Values = Inherit This is a key concept: **NULL or empty values inherit from the base.** ### Example ```text theme={"dark"} Base: Target URL: https://api.production.com Header: { "Authorization": "Bearer {{secret:prod_key}}" } Override: Target URL: null ← Use base (https://api.production.com) Header: { "Authorization": "Bearer {{secret:staging_key}}" } ← Override ``` **Result:** * Target URL: `https://api.production.com` (inherited) * Header: `Bearer staging_key` (overridden) *** ## Best Practices ### ✅ Do **1. Use descriptive environment names** ```text theme={"dark"} ✅ production, staging, development ✅ prod, stage, dev ✅ live, test, local ``` **2. Separate secrets per environment** ```text theme={"dark"} ✅ stripe_prod_key (production) ✅ stripe_test_key (staging/dev) ``` **3. Different client authorization** ```text theme={"dark"} Production: Only production servers Staging: Staging servers + QA team Development: All developer machines ``` **4. Test in staging first** ```text theme={"dark"} 1. Make changes in staging 2. Test thoroughly 3. Deploy to production ``` **5. Document environment purpose** ```text theme={"dark"} Development: Local testing, fake data Staging: Pre-prod testing, realistic data Production: Live customer traffic, real data ``` ### ❌ Don't **1. Don't use production secrets in dev** ```text theme={"dark"} ❌ development: stripe_prod_key (dangerous!) ✅ development: stripe_test_key ``` **2. Don't allow dev IPs in production** ```text theme={"dark"} ❌ Production clients: [dev-laptop-1, dev-laptop-2] ✅ Production clients: [prod-server-1, prod-server-2] ``` **3. Don't mix environments** ```text theme={"dark"} ❌ Staging using production database ✅ Staging using staging database ``` **4. Don't skip staging** ```text theme={"dark"} ❌ Dev → Production (risky!) ✅ Dev → Staging → Production ``` **5. Don't use generic names** ```text theme={"dark"} ❌ env1, env2, test ✅ staging, development ``` *** ## When to Create New Environments ### Create New Environments When: 1. **Different target systems** * Production API vs staging API vs local dev 2. **Different credentials** * Production keys vs test keys 3. **Different authorization** * Production servers vs developer laptops 4. **Different teams** * QA environment separate from dev 5. **Different regions** * US environment vs EU environment ### Don't Create Environments For: 1. **Different features** - Use different routes instead 2. **Different customers** - Use route parameters or headers 3. **Different versions** - Use API versioning in URLs 4. **Temporary testing** - Use staging environment *** ## Environment Workflows ### Development Workflow ```text theme={"dark"} 1. Developer creates route in UI ↓ 2. Tests in "development" environment curl ... -H "x-knoxcall-environment: development" ↓ 3. Works locally? → Push code to staging server ↓ 4. Test in "staging" environment curl ... -H "x-knoxcall-environment: staging" ↓ 5. Staging works? → Deploy to production ↓ 6. Test in "production" environment (carefully!) curl ... -H "x-knoxcall-environment: production" ``` ### Environment Promotion ```text theme={"dark"} Development: ✓ Local testing ✓ Rapid iteration ↓ Staging: ✓ Integration testing ✓ QA approval ↓ Production: ✓ Customer traffic ✓ Real transactions ``` *** ## Troubleshooting ### Environment Not Found **Error:** ```text theme={"dark"} Environment 'staging' not found for this route. Available environments can be configured in the admin panel. ``` **Solution:** 1. Go to **Resources** → **Environments** 2. Check if "staging" exists 3. Create it if missing 4. Verify spelling (case-sensitive) ### Using Wrong Environment **Symptom:** Request goes to wrong backend. **Solution:** 1. Check `x-knoxcall-environment` header value 2. Verify environment name spelling 3. Check which environment your route is configured for ### Override Not Working **Symptom:** Still using base configuration despite override. **Cause:** Override value is `null` (inheritance). **Solution:** 1. Edit route → **Environment** tab 2. Set specific value (not null) 3. Save changes *** ## Quick Reference | Concept | Explanation | | - | - | | **Environment** | Configuration context (dev, staging, prod) | | **Base Environment** | Default configuration (usually production) | | **Override** | Environment-specific customization | | **Inheritance** | NULL/empty values use base configuration | | **Switching** | Use `x-knoxcall-environment` header | *** ## Next Steps Advanced environment patterns Per-environment rate limits Create your first environment Environment-specific secrets *** **Key Takeaway**: Environments let you manage dev, staging, and production with **one route** instead of three. Override only what's different, inherit the rest! # Environment Deployment Workflows Source: https://docs.knoxcall.com/essentials/environments/environment-workflows Learn proven workflows for deploying changes from dev to staging to production safely using KnoxCall environments. # Environment Deployment Workflows Learn proven workflows for safely deploying changes from development to production using KnoxCall environments. ## Standard Three-Tier Workflow ```text theme={"dark"} Development → Staging → Production ``` This is the most common deployment pattern used by successful teams. ### Workflow Overview | Environment | Purpose | Stability | Users | | - | - | - | - | | **Development** | Feature development, experimentation | Unstable | Developers | | **Staging** | Pre-production testing, QA | Stable | QA team, stakeholders | | **Production** | Live customer-facing system | Very stable | End users | ## How KnoxCall Makes This Easy With KnoxCall, you don't need to change code or redeploy your client applications to switch environments. **Traditional approach:** ```javascript theme={"dark"} // Code changes needed const API_URL = process.env.NODE_ENV === 'prod' ? 'https://api.prod.com' : 'https://api.dev.com'; ``` **With KnoxCall:** ```javascript theme={"dark"} // No code changes - just an option const resp = await client.call("my-api", { path: "/api/users", environment: "staging" // Change only this }); ``` Same code, different backend via one option (or the `x-knoxcall-environment` header on raw HTTP)! ## Deployment Process ### Step 1: Develop in Development **What:** Make code changes and test locally **KnoxCall setup:** * Environment: `development` * Target URL: `http://localhost:3000` * Secrets: Dev API keys * Clients: Your laptop IP * Rate limits: Unlimited **Test your changes** (`$TOKEN` is a minted OAuth access token — see [Authentication](/api-reference/authentication)): ```bash theme={"dark"} curl -X POST "https://a1b2c3d4.DunderMifflin.knoxcall.com/api/users" \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: user-api" \ -H "x-knoxcall-environment: development" \ -d '{"name": "Test User"}' ``` Routes to: `http://localhost:3000/api/users` **Iterate quickly:** * Make changes to local backend * Test immediately * No deployment needed ### Step 2: Promote to Staging **What:** Deploy backend to staging server, run full QA **KnoxCall setup:** * Environment: `staging` * Target URL: `https://staging-api.yourcompany.com` * Secrets: Staging API keys * Clients: Staging server IPs, QA team IPs * Rate limits: Moderate (1000/min) **Deploy backend:** ```bash theme={"dark"} # Deploy your backend to staging server git push staging main # Or: deploy via CI/CD ``` **Test with staging environment:** ```bash theme={"dark"} curl -X POST "https://a1b2c3d4.DunderMifflin.knoxcall.com/api/users" \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: user-api" \ -H "x-knoxcall-environment: staging" \ -d '{"name": "Test User"}' ``` Routes to: `https://staging-api.yourcompany.com/api/users` **Staging checklist:** * [ ] All unit tests pass * [ ] Integration tests pass * [ ] Manual QA completed * [ ] Performance acceptable * [ ] Security scan passed * [ ] No critical bugs * [ ] Stakeholder approval ### Step 3: Deploy to Production **What:** Deploy to live environment after thorough testing **KnoxCall setup:** * Environment: `production` (base environment) * Target URL: `https://api.yourcompany.com` * Secrets: Production API keys * Clients: Production server IPs only * Rate limits: Strict (100/min) **Deploy backend:** ```bash theme={"dark"} # Deploy to production git push production main # Or: deploy via CI/CD with approval gate ``` **Production requests** (no environment header needed): ```bash theme={"dark"} curl -X POST "https://a1b2c3d4.DunderMifflin.knoxcall.com/api/users" \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: user-api" \ -d '{"name": "Real User"}' ``` Routes to: `https://api.yourcompany.com/api/users` (default/base environment) **Post-deployment monitoring:** * [ ] Monitor error rates in KnoxCall dashboard * [ ] Check response times * [ ] Watch for spike in 5xx errors * [ ] Verify no increase in failed requests * [ ] Monitor user reports/support tickets ## Environment-Specific Configuration ### Different Rate Limits Per Environment **Development:** Relaxed for testing ```text theme={"dark"} Rate limit: 10,000 requests/minute (or unlimited) Purpose: Allow rapid iteration ``` **Staging:** Moderate limits ```text theme={"dark"} Rate limit: 1,000 requests/minute Purpose: Test rate limiting behavior, allow load testing ``` **Production:** Strict protection ```text theme={"dark"} Rate limit: 100 requests/minute per client Purpose: Protect backend from abuse ``` **Configure in KnoxCall:** 1. Edit route 2. Go to **Environment** tab 3. Set different `rate_limit_requests` and `rate_limit_window_sec` per environment ### Different Secrets Per Environment Use environment-specific secrets for security: **Development route override:** ```json theme={"dark"} { "inject_headers": { "Authorization": "Bearer {{secret:stripe_dev_key}}" } } ``` **Staging route override:** ```json theme={"dark"} { "inject_headers": { "Authorization": "Bearer {{secret:stripe_staging_key}}" } } ``` **Production (base route):** ```json theme={"dark"} { "inject_headers": { "Authorization": "Bearer {{secret:stripe_prod_key}}" } } ``` Now each environment uses its own Stripe key automatically! ### Different Monitoring Thresholds **Development:** No alerts (expect errors during development) **Staging:** Warning-level alerts * Alert if error rate > 20% * Alert if P95 latency > 5 seconds * Send to Slack `#staging-alerts` channel **Production:** Critical alerts * Alert if error rate > 5% * Alert if P95 latency > 1 second * Send to PagerDuty for on-call rotation * Send to Slack `#production-alerts` channel **Configure in KnoxCall:** 1. Navigate to **Alerts** 2. Create alert rules with environment filters 3. Set different thresholds per environment 4. Configure notification channels ## Rollback Strategy If production deployment fails, you can roll back instantly without code changes. ### Quick Rollback via Environment Override **Scenario:** New backend version (v2) has critical bug **Option 1: Point production to old backend** 1. Edit route → **Environment** tab 2. Add override for "production" environment 3. Set target URL to old backend: ``` https://api-v1.yourcompany.com (old version) ``` 4. Save Traffic instantly goes to old backend. No code changes needed! **Option 2: Switch production to use staging** Temporarily point production traffic to staging backend: 1. Edit route → **Environment** tab 2. Override production target URL: ``` https://staging-api.yourcompany.com ``` 3. Save This buys time to fix the bug while serving from last known good version. ### Full Rollback Workflow ```text theme={"dark"} 1. Identify issue in production ↓ 2. Immediately rollback route to old backend URL ↓ 3. Investigate issue in staging ↓ 4. Fix bug and test in development ↓ 5. Validate fix in staging ↓ 6. Redeploy to production ↓ 7. Update route back to new backend URL ``` ## Advanced Deployment Patterns ### Feature Branch Environments For large features, create temporary environments: **Example: New payment system** 1. Create environment: `feature-payment-v2` 2. Configure route override: * Target URL: `https://payment-v2-api.yourcompany.com` * Secrets: `payment_v2_api_key` 3. Test feature branch in isolation 4. After merge: Delete `feature-payment-v2` environment **Benefits:** * Test big changes safely * Don't interfere with staging * Easy cleanup after merge ### Canary Deployments Gradually roll out to production: **Phase 1: Internal testing (5% of traffic)** ```text theme={"dark"} Environment: production-canary Target: https://api-v2.yourcompany.com (new version) Clients: Internal testing IPs only ``` **Phase 2: Beta users (20% of traffic)** ```text theme={"dark"} Environment: production-beta Target: https://api-v2.yourcompany.com Clients: Beta tester IPs ``` **Phase 3: Full rollout (100% of traffic)** ```text theme={"dark"} Environment: production (base) Target: https://api-v2.yourcompany.com Clients: All production IPs ``` ### Blue-Green Deployments Run two identical production environments: **Blue (current production):** ```text theme={"dark"} Environment: production Target: https://api-blue.yourcompany.com (v1.5) ``` **Green (new version):** ```text theme={"dark"} Environment: production-green Target: https://api-green.yourcompany.com (v1.6) ``` **Switch traffic:** Update base route to point to green **Instant rollback:** Switch back to blue if issues ### Regional Environments For global applications, route by region: ```text theme={"dark"} production-us-east production-us-west production-eu-west production-asia-pacific ``` Client application determines region and sets environment header accordingly. **Benefits:** * Data sovereignty compliance * Reduced latency (users hit closest region) * Region-specific deployments * Isolated failures (one region down doesn't affect others) ## Monitoring Across Environments ### Compare Metrics Dashboard Create a dashboard showing all environments side-by-side: | Metric | Development | Staging | Production | | - | - | - | - | | **Request volume** | 50/min | 500/min | 5,000/min | | **Error rate** | 15% (expected) | 2% | 0.5% | | **P95 latency** | 2s | 500ms | 200ms | | **Success rate** | 85% | 98% | 99.5% | **In KnoxCall:** 1. Navigate to **Analytics** 2. Group by "Environment" 3. Compare metrics across environments 4. Spot differences between staging and production ### Environment Health Dashboard Monitor the overall health of each environment: ```text theme={"dark"} ✅ Development: Healthy (expected errors during dev) ⚠️ Staging: Warning - High error rate (needs investigation) 🔴 Production: Critical - Service degraded (page on-call) ``` ## Troubleshooting Deployment Issues ### Changes Not Showing in Staging **Symptoms:** Made changes, but staging still shows old behavior **Possible causes:** 1. Backend not deployed yet 2. Wrong environment header in request 3. Caching (client-side or CDN) 4. Environment override not configured **Debug steps:** ```bash theme={"dark"} # 1. Verify backend deployment ssh staging-server ps aux | grep myapp curl localhost:3000/health # 2. Test with correct header curl https://a1b2c3d4.DunderMifflin.knoxcall.com/api/users \ -H "x-knoxcall-environment: staging" \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: my-api" # 3. Check KnoxCall logs # Go to Logs → Filter by environment: staging # Verify request hit staging backend # 4. Verify environment override # Routes → Your Route → Environment tab → select Staging # Check target URL is correct ``` ### Different Behavior Between Environments **Symptoms:** Works in staging, fails in production **Possible causes:** 1. Different secrets (dev keys vs prod keys) 2. Different backend code versions 3. Different environment variables 4. Different database state 5. Different rate limits causing throttling **Debug checklist:** * [ ] Compare route configurations (base vs overrides) * [ ] Verify secrets are environment-specific * [ ] Check backend versions match * [ ] Compare environment variables * [ ] Review rate limit settings * [ ] Check database schema versions ## Pre-Deployment Checklist Before each production deployment, complete this checklist: ### Pre-Deployment * [ ] All tests passing in staging * [ ] Stakeholder approval received * [ ] Rollback plan documented * [ ] Team notified in Slack * [ ] Monitoring dashboard ready * [ ] On-call engineer identified * [ ] Database migrations tested (if any) * [ ] Feature flags configured (if applicable) ### During Deployment * [ ] Deploy backend to production * [ ] Verify health endpoint responds * [ ] Test critical user flows manually * [ ] Monitor error rates in real-time * [ ] Check latency metrics * [ ] Review first 100 production requests ### Post-Deployment * [ ] Monitor for 30 minutes * [ ] Review error logs (should be stable) * [ ] Check analytics (traffic patterns normal) * [ ] Verify zero increase in support tickets * [ ] Update team status: "Deployment successful" * [ ] Update documentation (if needed) * [ ] Mark deployment as stable in tracking system ## Best Practices Summary ✅ **Do:** * Always test in development first * Require QA approval for staging → production * Set production as default environment * Use different API keys per environment * Monitor each environment separately * Document rollback procedures * Keep staging close to production config * Run load tests in staging ❌ **Don't:** * Deploy directly to production without staging * Use production API keys in development * Skip the staging environment ("it's just a small change") * Forget to monitor after deployment * Make manual changes directly in production * Test in production (use staging!) ## Next Steps Now that you understand deployment workflows: Set up environment-specific alerts Manage secrets per environment Configure limits per environment Track configuration changes ## Related Concepts * **Base Route**: Default production configuration * **Environment Override**: Environment-specific settings (NULL = inherit) * **Default Environment**: Used when no header specified * **Per-environment clients**: IP whitelist per environment * **Rollback**: Point route to previous backend version *** * **Level**: intermediate * **Time**: 15 minutes `environments`, `deployment`, `workflows`, `devops`, `staging` # Ephemeral Proxy overview Source: https://docs.knoxcall.com/essentials/ephemeral-proxy/overview Proxy any HTTPS request through KnoxCall without registering a Route first. Useful for ad-hoc API calls, agent runtimes, and one-shot integrations that don't justify a permanent Route. # Ephemeral Proxy overview KnoxCall's **Ephemeral Proxy** lets you proxy any HTTPS request through KnoxCall without registering a [Route](/essentials/routes/what-are-routes) first. Pass the target URL in the `X-Knox-Proxy-URL` header and send your payload as the request body; KnoxCall resolves any Vault token references, makes the request, and streams the response back verbatim. Think of it as Routes' lower-friction cousin: same proxy core (secrets injection, egress controls, audit) without the upfront configuration step. ## Why use it | Problem | Without ephemeral | With ephemeral | | - | - | - | | Agent runtime needs to call an arbitrary URL once | Pre-register every URL it might hit (impossible) | One-shot proxy with the same secrets + audit story | | Internal script needs to call an API, but not enough volume to deserve a Route | Either register it (config noise) or call it directly (audit gap) | Use ephemeral; gets logged like any Route call | | Testing a new integration before committing to it | Build a Route, throw it away after | Ephemeral; nothing to clean up | | Multi-tenant SaaS where each tenant's URLs are unknown ahead of time | Can't pre-register | Ephemeral handles unknown URLs at runtime | ## How it differs from Routes | | Routes | Ephemeral | | - | - | - | | **Setup** | Register the URL, method, secrets, environment overrides | None — pass everything per-call | | **API path** | `/proxy/{route_name}` | `/v1/proxy` | | **URL** | Fixed at registration | Per-call | | **Secrets** | Reference by name (configured on the Route) | Reference by name (looked up per-call) | | **Audit row** | `proxy_mode='route'` | `proxy_mode='ephemeral'` (filterable in API Logs) | | **Best for** | Integrations you call repeatedly with stable config | One-off calls, agent runtimes, ad-hoc work | The shared infrastructure (secrets, egress allowlist, rate limits, SSRF guard) applies to **both**. ## Quick start ```bash theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) curl -X POST https://api.knoxcall.com/v1/proxy \ -H "Authorization: Bearer $TOKEN" \ -H "X-Knox-Proxy-URL: https://api.example.com/orders" \ -H "Content-Type: application/json" \ -d '{ "customer_id": "{{ token: tok_J8K2M4N5P6Q7R8 }}", "amount": 4999 }' ``` The HTTP method you use (`POST` above) is forwarded as-is. KnoxCall will: 1. **Read the target URL** from the `X-Knox-Proxy-URL` header. 2. **Resolve `{{ token: ... }}`** references in the body or query string against your [Vaults](/essentials/vaults/overview) (detokenizes, audits the read). 3. **Validate the URL**: HTTPS only, hostname must resolve, IP must not be private/metadata. 4. **Make the call** through KnoxCall's egress (static IP if you've configured one). 5. **Stream the response back** verbatim — same status code, same headers (minus hop-by-hop), same body bytes. 6. **Audit** with `proxy_mode='ephemeral'` so you can filter the calls in API Logs. ## Templating Template expressions in the **request body** and **query string** are resolved before the call is forwarded: * `{{ token: }}` — detokenize from a Vault. Token ids are tenant-globally unique, so you don't qualify them with a vault name. Audited per-token. * `{{ encrypted | json: $.path }}` — reads a value at the given JSONPath out of the base64-encoded JSON you pass in the `X-Knox-Encrypted` request header and substitutes it into the body/query. * `\{{ ... }}` — an escape that renders a literal `{{ ... }}`. Anything brace-shaped that is *not* one of the two forms above is refused with `400 template_error` instead of being forwarded, so escape braces you intend the upstream to receive. **Headers are not templated** — the template engine does not process `{{ ... }}` expressions in headers in v1. To inject a secret into an outbound `Authorization` header, use a [Route](/essentials/routes/what-are-routes) instead. See the [Invoke reference](/api-reference/ephemeral-proxy/invoke) Templates section for the full spec. ## Plan limits | Tier | Enabled | Ops/mo | RPS limit | Tokens per request | | - | - | - | - | - | | Free | no | 0 | 0 | 20 | | Starter | yes | 100k | 10 | 20 | | Pro | yes | 1M | 100 | 20 | | Enterprise | yes | unlimited | unlimited | 100 | The per-request token cap in the table reflects plan config (Enterprise = 100). In v1 the handler enforces a fixed cap of **20 tokens per request** across all plans; the per-plan Enterprise value is not yet wired into the handler. The Free tier is **off by default** because the unbounded "any URL with API-key" surface is the most-abused KnoxCall capability. Enterprise tenants who only want pre-configured Routes can override to `false` even on a paid plan. ## Security * **HTTPS only**. Plain HTTP is rejected. * **SSRF guard**: hostname must resolve to a public IP. Private (RFC1918), link-local, loopback, and metadata IPs (`169.254.169.254`) are blocked. * **Tenant scoping**: secrets and Vault tokens are scoped to your tenant; ephemeral calls can't reference other tenants' material. * **Audit**: every call hits API Logs with `proxy_mode='ephemeral'`. ## Next steps * [Routes overview →](/essentials/routes/what-are-routes) — for repeated calls with stable config * [Vaults →](/essentials/vaults/overview) — what `{{ token: ... }}` references * [Secrets →](/essentials/secrets/secrets-overview) — what `{{ secret: ... }}` references # Inbound Webhook format guide Source: https://docs.knoxcall.com/essentials/inbound-webhooks/format-guide Wire-level details for each HMAC format KnoxCall verifies — header names, signed-payload construction, replay-protection behaviour. # Inbound Webhook format guide This page is the wire-level reference for each format the [Inbound Webhooks verifier](/essentials/inbound-webhooks/overview) supports. If you're integrating a custom provider, the rules below tell you exactly what KnoxCall computes when it verifies a request. All formats use **HMAC-SHA256** with the shared secret you configured on the subscription. Comparisons are constant-time (`crypto.timingSafeEqual`) — there's no timing oracle to exploit. ## Stripe **Header:** `Stripe-Signature: t=,v1=` (multiple `v1=...` pairs accepted — Stripe rotates secrets this way during a cutover window) **Signed payload:** `.` — the `t=` value, a literal `.`, then the raw request body. **Replay protection:** yes — KnoxCall rejects with `replay_window_exceeded` if `now - t > replay_window_seconds`. Default 300 (5 min) matches Stripe's recommendation. **Compatibility:** anything that adopted Stripe's signature format. The verifier accepts a request when **any** of the listed `v1=` values matches; that's how Stripe handles secret rotation. If you rotate the secret on KnoxCall, an in-flight cutover where the upstream sends both old and new signatures will keep working until you update the upstream config. ## GitHub **Header:** `X-Hub-Signature-256: sha256=` **Signed payload:** the raw body bytes only. **Replay protection:** no — GitHub webhooks include a delivery ID but don't sign it, so KnoxCall can't enforce a timestamp window. If your application can't tolerate replay (e.g. webhooks that mutate state by content rather than idempotency key), enforce the check on your side using GitHub's `X-GitHub-Delivery` header. **Compatibility:** GitHub webhooks, Postmark, SendGrid Inbound Parse, and most internal SaaS that copied GitHub's scheme. ## Slack **Headers:** * `X-Slack-Signature: v0=` * `X-Slack-Request-Timestamp: ` **Signed payload:** `v0::` — the literal string `v0:`, the timestamp, another `:`, then the raw body. **Replay protection:** yes — KnoxCall enforces the configured window against `X-Slack-Request-Timestamp`. Default 300 matches Slack's documented recommendation. **Compatibility:** Slack events, slash commands, interactive components. ## AWS SNS (HMAC variant) **Headers (outbound):** * `x-amz-sns-signature: ` * `x-amz-sns-signature-version: 1-hmac` **Signed payload:** the raw body only. **Replay protection:** no. **Inbound verification:** KnoxCall checks only `x-amz-sns-signature`. The `x-amz-sns-signature-version` header is present on outbound deliveries but not required for inbound verification. > ⚠️ This is **not** real AWS SNS. Real SNS uses RSA over a canonicalised message envelope, not HMAC. KnoxCall's `aws-sns` format is an HMAC variant intended for KnoxCall-to-KnoxCall flows or internal services that wanted SNS-shaped headers without the RSA tooling. If you're verifying real AWS-issued SNS messages, this format won't match — let us know if RSA SNS is a deal-breaker for you. ## KnoxCall legacy **Header:** `X-Webhook-Signature: sha256=` **Signed payload:** the raw body only. **Replay protection:** no. This is the format KnoxCall's outbound [Webhook Signing](/essentials/webhook-signing/overview) emitted before the multi-format library landed. Use it when both ends are KnoxCall (KnoxCall-to-KnoxCall internal events, or one KnoxCall tenant fanning to another). ## Custom **Header:** *(caller-named, e.g. `X-MyApp-Signature`)* with value `sha256=` **Signed payload:** the raw body only. **Replay protection:** no. For internal tools that don't match a named format. Set `custom_header_name` when you create the subscription. The verifier only knows that *one* HMAC-SHA256 of the body is in the named header — it doesn't try to be clever about timestamps. ## Reproducing a verification yourself If you want to confirm KnoxCall's behaviour from the outside (or reproduce why a request failed), here's the GitHub-format check in seven lines of Node: ```javascript theme={"dark"} import crypto from 'node:crypto'; const secret = 'whsec_...'; const body = '...raw bytes from the request...'; const header = 'sha256=...'; // X-Hub-Signature-256 const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(body).digest('hex'); const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header)); console.log(ok); // → true if KnoxCall would also accept this request ``` For Stripe, the body becomes `${timestamp}.${rawBody}` and the header parses out as `t=...,v1=...`. The Slack version uses `v0:${timestamp}:${rawBody}` and the `X-Slack-Signature: v0=` header. The verifier's source is not public, so this page is its specification: the per-format rules above are what it computes. ## What happens when verification fails KnoxCall surfaces the precise reason in the `signature_error` column of API Logs and (when forwarding with `on_invalid='forward_with_error_header'`) in the `X-Knox-Verify-Reason` HTTP header. Common values: | Reason | Meaning | | - | - | | `missing
header` | The request didn't include the header KnoxCall expected for this format | | `malformed
` | The header was present but didn't match the format's regex (e.g. not `sha256=<64-hex>`) | | `replay_window_exceeded` | Timestamp was outside `[now − replay_window_seconds, now + replay_window_seconds]` | | `no v1 signature matched` (Stripe) | Header parsed correctly but none of the listed `v1=` values verified — most often a wrong-secret or rotated-secret-on-only-one-side scenario | | `Stripe-Signature missing t=` | The Stripe header didn't carry a timestamp; usually a misconfigured custom shim claiming to be Stripe | ## Next steps * [Inbound Webhooks overview →](/essentials/inbound-webhooks/overview) * [Outbound Webhook Signing →](/essentials/webhook-signing/overview) — the inverse direction # Inbound Webhooks overview Source: https://docs.knoxcall.com/essentials/inbound-webhooks/overview Verify HMAC-signed webhooks from Stripe, GitHub, Slack and others without writing verification code in every service. KnoxCall checks the signature, audits the request, and forwards it to your app. # Inbound Webhooks overview KnoxCall's **Inbound Webhooks** feature is a **signature-verifying ingress** for HMAC-signed webhooks. Point Stripe (or GitHub, Slack, AWS SNS, anything that signs requests) at a KnoxCall URL. KnoxCall verifies the signature, audits the call, and optionally forwards the request to your app with an `X-Knox-Verified: true` header. The job your app *would* do — verify the HMAC, enforce the replay window, log the result — lives once in KnoxCall instead of being copy-pasted across every service that consumes webhooks. ## Why use it | Problem | Without KnoxCall | With KnoxCall | | - | - | - | | Each service that consumes Stripe webhooks reimplements verification | One mistake = signature bypass for that whole service | Verification happens once at the edge, your app trusts the `X-Knox-Verified` header | | Replay-attack windows (Stripe, Slack) drift across codebases | Easy to forget; SDK upgrades change defaults | KnoxCall enforces the configured window per subscription, not per app | | Audit trail of "did this webhook signature match?" | Per-app, by log line, often missing | Every ingest writes a row to KnoxCall's API logs with verified=true/false | | Rotating a webhook secret | Find every service, redeploy each | Rotate in the admin UI; old secret stops working immediately | | Webhook-source IP filtering | Maintain Stripe/GitHub IP allowlists in every NAT/firewall | Customer-facing URL is on a single hostname, easy to allow | ## How it works 1. **Create an inbound webhook subscription** in the admin UI — pick a format (Stripe, GitHub, Slack, AWS SNS, KnoxCall legacy, or custom-header), paste the shared secret, choose where to forward verified requests. 2. KnoxCall hands you a **receiver URL**: `https://hooks.knoxcall.com//`. 3. Paste that URL into the upstream provider's webhook config. Stripe (or whoever) signs every event with HMAC-SHA256 of the body and sends it to KnoxCall. 4. KnoxCall **verifies the signature** using the format's wire spec and the secret you configured. 5. If valid → forward to your app with `X-Knox-Verified: true` (or audit-only if no forward URL). 6. If invalid → return `401` (default), or forward with `X-Knox-Verified: false` if you want your app to log unverified events without losing them. Either way, the call lands in **API Logs** so you can see verification rate, replay-rejected requests, and forward latency. ## Quick start (UI) 1. Go to **Automation → Webhooks** in the admin UI, switch to the **Inbound** tab, and click **New Inbound Webhook**. 2. Pick the **format** that matches the upstream provider (Stripe, GitHub, Slack, etc.). 3. Paste the **shared secret** the provider gave you (e.g. Stripe's `whsec_...`). 4. Optional: enter a **forward URL** (your app's webhook handler). Leave blank for verify-only auditing. 5. Save. Copy the **receiver URL** that appears. 6. Paste the receiver URL into the upstream provider's webhook configuration. That's it. The next event from the upstream provider will hit KnoxCall, get verified, and either show up in your app's webhook handler with `X-Knox-Verified: true` or appear in **API Logs** for inspection. ## Supported formats | Format | Header | Replay protection | Use case | | - | - | - | - | | **Stripe** | `Stripe-Signature: t=,v1=` | yes (timestamp `t=`) | Stripe webhooks, any provider that adopted the format | | **GitHub** | `X-Hub-Signature-256: sha256=` | no (no timestamp) | GitHub webhooks, Postmark, SendGrid, many SaaS | | **Slack** | `X-Slack-Signature: v0=` + `X-Slack-Request-Timestamp` | yes | Slack events, slash commands | | **AWS SNS (HMAC)** | `x-amz-sns-signature` (base64) | no | KnoxCall-shaped SNS receivers (not real AWS RSA-SNS) | | **KnoxCall legacy** | `X-Webhook-Signature: sha256=` | no | KnoxCall-to-KnoxCall flows | | **Custom** | caller-named header `sha256=` | no | Internal tools that don't match a named provider | See the [format guide](/essentials/inbound-webhooks/format-guide) for the exact wire format each verifier expects. ## Replay protection For formats with a timestamp header (Stripe, Slack), KnoxCall enforces a **5-minute window** by default. A request with a timestamp older than that gets rejected with `replay_window_exceeded` even if the signature is valid. This closes the attack where someone captures a real webhook and replays it later. Configure the window per subscription (range: 0–86400 seconds, where 0 disables the check). The default of 300 matches Stripe's and Slack's recommended values. ## What gets audited Every ingest call writes a row to **API Logs** (`/logs`) with: * `path` — `//` * `signature_valid` — `true` / `false` * `signature_error` — the rejection reason if invalid (`replay_window_exceeded`, `no v1 signature matched`, `missing X-Hub-Signature-256 header`, etc.) * `meta_json.subscription_id` — which subscription handled the request * `meta_json.forwarded` — whether the verified request was sent on to your app * `meta_json.forward_status` — HTTP status from your app Filter the logs by webhook name to see verification health for one provider. ## Security model KnoxCall stores the shared secret encrypted under the **per-tenant master key** (KCT1 envelope). The receiver URL is **unauthenticated by design** — authentication *is* the HMAC signature. A request that doesn't verify either gets dropped (default) or marked `X-Knox-Verified: false` for your app to handle. Forwarding is **SSRF-safe**: the forward URL is validated to use HTTPS, point at a DNS hostname (not a literal IP), and not resolve to a private / metadata-IP range. ## Next steps * [Format guide →](/essentials/inbound-webhooks/format-guide) * [Outbound Webhook Signing →](/essentials/webhook-signing/overview) — the inverse direction (KnoxCall fires events out and signs them) * [Crypto Keys →](/essentials/crypto-keys/crypto-keys-overview) — use a managed key for signature verification instead of a per-subscription shared secret # BYOK and KMS providers Source: https://docs.knoxcall.com/essentials/key-management/byok-and-kms Wrap your tenant master key with a KMS key — operator-side or your own (BYOK). Revoke our access in one IAM change. By default, every KnoxCall tenant's master key is wrapped with the deployment's `MASTER_KEY_B64` (the `global` wrap method). That gives you per-tenant cryptographic isolation, but the wrapping key is on the same compute as KnoxCall. If your security posture requires the wrapping key to live elsewhere — in a hardware-backed HSM, in a separate AWS account, or under your own IAM control — you opt into **KMS-backed wrapping**. Two flavours: * **Operator KMS** — KnoxCall manages a KMS key on your behalf (in our cloud account). * **Customer KMS (BYOK)** — the KMS key lives in **your** cloud account. You can revoke our access in one IAM change. ## When to use each | If you want… | Pick | | - | - | | Default — minimal config, same crypto + per-tenant isolation | `global` (no setup) | | KMS audit + hardware-backed wrapping, low ops burden | `operator_kms` | | Maximum sovereignty, kill-switch in your IAM, regulatory requirement | `customer_kms` (BYOK) | If you don't have a regulatory or sovereignty requirement, `global` is the right answer — it's not weaker crypto, it's just a different blast-radius story. ## What gets wrapped vs encrypted directly * The **tenant master key** itself is wrapped by your KMS key. * Everything **inside** KnoxCall — secrets, DB admin passwords, CA private keys, transit-key versions — is encrypted with the **tenant master key** using AES-256-GCM, locally on KnoxCall's compute. KMS is in the path **once per cache miss** (default 1-hour TTL). It's not in the path of every encrypt / decrypt, so your data ops aren't gated on KMS latency or availability between cache flushes. ## Operator KMS KnoxCall operates a KMS key in our cloud account. Tenant master keys are wrapped with it. You don't manage IAM, the key, or rotation — we do. * ✅ Hardware-backed wrapping with KMS audit. * ✅ Minimal config — pick the provider, we handle the rest. * ⚠️ KnoxCall holds the wrapping key. Revoking access requires reaching out to us (or destroying the tenant entirely, which works because of cryptographic erasure). Pick this if you want KMS-grade wrapping but don't need a kill switch in your own cloud account. ## Customer KMS (BYOK) The wrapping key lives in **your** AWS / GCP / Azure account. KnoxCall is granted IAM permission to call `Encrypt` / `Decrypt` against that one key — nothing else. * ✅ Strongest sovereignty: revoke our IAM grant and we can't unwrap on the next cache miss. * ✅ Your KMS audit log records every unwrap call we make. * ✅ Required by some regulated buyers (FedRAMP, HIPAA, certain EU data-residency stories). * ⚠️ You're responsible for IAM, key rotation, and not breaking it during your own cloud changes. When you "lock us out" by revoking the grant, every tenant whose master key is wrapped by that KMS key goes [sealed](/essentials/key-management/tenant-master-key#sealed-state) on the next cache miss. The cache TTL bounds how long stale unwraps remain usable. ## Setup: AWS BYOK For the full step-by-step setup guide including GCP and Azure, see the [Tenant KMS (BYOK) section](/essentials/tenant-kms/overview). This page covers the concepts and background; the Tenant KMS section has the operational runbooks. Customer KMS (BYOK) tenants have a **5-minute session cache TTL** instead of the default 1 hour. Agent sessions renew more frequently — plan for this in self-hosted proxy deployments. ### 1. Create a KMS key in your AWS account ```bash theme={"dark"} # In your AWS account, your region: aws kms create-key \ --description "KnoxCall tenant master key wrapping" \ --key-usage ENCRYPT_DECRYPT \ --key-spec SYMMETRIC_DEFAULT # → Note the KeyId / Arn ``` ### 2. Grant KnoxCall's IAM principal access KnoxCall assumes a role in your account via STS using a customer-supplied `role_arn` and `external_id`. Create a role with this trust policy in **your** account: ```json theme={"dark"} { "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam:::root" }, "Action": "sts:AssumeRole", "Condition": { "StringEquals": { "sts:ExternalId": "" } } }] } ``` KnoxCall shows `` in the BYOK setup wizard (**Settings → Security → Tenant Master Key → Customer KMS → Set up BYOK**, AWS step) along with a ready-to-paste copy of this trust policy; it's also returned by `GET /admin/tenant-kms/provider-identity`. The `` is anything you generate (UUID is fine) — you'll paste it into the admin UI. Attach this inline policy to the role, restricted to the one KMS key: ```json theme={"dark"} { "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Action": ["kms:Encrypt", "kms:Decrypt"], "Resource": "" }] } ``` ### 3. Configure the tenant in KnoxCall In the admin UI: **Settings → Security → Tenant Master Key → Customer KMS → Set up BYOK**, select **AWS KMS**. Provide: * **AWS region** of the KMS key. * **KMS key ARN** (or alias ARN). * **Role ARN** of the role you created in step 2. * **External ID** matching the trust policy. Or via API: ```bash theme={"dark"} curl -X POST https://api.knoxcall.com/admin/tenant-kms \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "X-Tenant-ID: $KC_TENANT_ID" \ -H "Content-Type: application/json" \ -d '{ "provider": "aws", "kms_key_ref": "arn:aws:kms:us-east-1:111122223333:key/abcd-...", "config": { "region": "us-east-1", "role_arn": "arn:aws:iam::111122223333:role/KnoxCallKMSAccess", "external_id": "your-chosen-external-id" } }' ``` ### 4. KnoxCall verifies the key On save, KnoxCall does a probe encrypt + decrypt round-trip to prove the IAM grant works. The result lands in `last_verified_at` / `last_verify_error` on the config row. If verification fails, the change isn't applied — you don't end up with a half-broken config. ### 5. Provision the tenant master key under the new wrap After verification succeeds, KnoxCall provisions a new tenant master key version wrapped with the KMS key. The new version becomes active; the previous (`global`-wrapped) version is retired. **Existing data stays wrapped under the retired version** until a rewrap migration runs. New writes use the new active version. ## What credentials look like in the config Per the schema, `tenant_kms_config.config` is **non-secret configuration only** — region, role\_arn, external\_id. Static credentials (AWS access keys, GCP service account JSON files, Azure client secrets) are **rejected at the API level** — not just discouraged. Cross-account access is always via STS-assumed roles for `customer_kms`. Operator KMS uses the deployment's IAM role. ## Provider support today | Provider | Status | Notes | | - | - | - | | `global` | ✅ Shipped | Default; wraps with `MASTER_KEY_B64`. | | `operator_kms_aws` | ✅ Shipped | KnoxCall-managed AWS KMS key. | | `customer_kms_aws` | ✅ Shipped | BYOK in your AWS account. STS cross-account. | | `operator_kms_gcp` | ✅ Shipped | KnoxCall-managed GCP Cloud KMS key. | | `customer_kms_gcp` | ✅ Shipped | BYOK in your GCP project. Workload Identity Federation. | | `operator_kms_azure` | ✅ Shipped | KnoxCall-managed Azure Key Vault key. | | `customer_kms_azure` | ✅ Shipped | BYOK in your Azure subscription. Federated Credentials. | ## Revoking BYOK access (the kill switch) If you ever need to lock KnoxCall out: 1. Remove or detach the IAM policy that lets the assumed role call `kms:Decrypt` on your key. 2. Within the cache TTL (**5 minutes** for BYOK tenants), every tenant under this BYOK config will go sealed. 3. New agent sessions fail; existing agents continue until their current session expires (at most 5 minutes). Revoke the **key permission**, not just the identity. Permission checks (`kms:Decrypt` on AWS, the KMS role binding on GCP, the Key Vault role assignment on Azure) are evaluated on every call, so they take effect within the 5-minute cache TTL. Revoking only the *identity path* (the AWS role's trust policy, a GCP Workload Identity binding, an Azure federated credential) blocks new sessions but not credentials KnoxCall already holds — those persist up to \~15 minutes on AWS, \~5 minutes on GCP, and up to your Entra access-token lifetime (typically \~60–90 minutes) on Azure. Doing both is fine; the key permission is the one that guarantees the window. To **destroy** the data instead of just freezing it: revoke the **tenant master key** itself in KnoxCall (cryptographic erasure — see [tenant master key](/essentials/key-management/tenant-master-key#cryptographic-erasure-the-kill-switch)). The KMS key in your account is then irrelevant — the master key is gone. ## See also * [Tenant master key →](/essentials/key-management/tenant-master-key) * [Tenant KMS (BYOK) overview →](/essentials/tenant-kms/overview) # Tenant master key Source: https://docs.knoxcall.com/essentials/key-management/tenant-master-key Every tenant has its own master key. It wraps everything else KnoxCall encrypts on your behalf — and it's the lever for cryptographic erasure. # Tenant Master Key Every KnoxCall tenant has its own **master key** — a 32-byte symmetric key that wraps every other piece of encrypted material the tenant owns. Secrets, database admin passwords, SSH keys, CA private keys, transit-key versions: all of it is wrapped (KCT1 envelope format) by the tenant master key. This is the load-bearing isolation primitive in KnoxCall. A bug in the control plane, a stolen backup, or a malicious insider hitting the wrong row in the database can't leak data across tenants — every tenant's data is wrapped with **its** key, and you can't unwrap one tenant's blob with another tenant's key. The Vault parallel: this is your unseal key, except per-tenant rather than per-cluster, and unwrapped on demand from a configurable backing store rather than at startup. ## How it's stored The master key isn't stored in plaintext anywhere. It's stored **wrapped** in the `tenant_master_keys` table, and the wrapping method is configurable per tenant. There are three: | Wrap method | What wraps the master key | Where the wrapping key lives | Sovereignty | | - | - | - | - | | `global` | `MASTER_KEY_B64` (the deployment's env var) | KnoxCall's process env | Default — same as today, plus per-tenant isolation | | `operator_kms` | A KMS key in **KnoxCall's** cloud account | AWS / GCP / Azure (KnoxCall-operated) | Strong (KMS audit, hardware-backed) | | `customer_kms` | A KMS key in **your** cloud account | AWS / GCP / Azure (yours) | Strongest — [BYOK](/essentials/key-management/byok-and-kms) | See the [BYOK & KMS guide](/essentials/key-management/byok-and-kms) for how to set up `operator_kms` or `customer_kms`. Without explicit configuration, every tenant uses `global` — same crypto today, plus per-tenant isolation. ## How it's used Every per-tenant crypto operation goes through `TenantMasterKeyManager`. The flow: 1. Caller wants to encrypt or decrypt something for tenant `T`. 2. Manager checks its in-memory cache for `T`'s unwrapped master key. 3. **Cache hit** → use the cached key. 4. **Cache miss** → load the wrapped row, dispatch to the correct provider (global / AWS / future GCP / future Azure) to unwrap, cache for an hour. 5. Concurrent misses for the same tenant **deduplicate** — N parallel requests after a cache flush trigger one unwrap, not N. The cache TTL is 1 hour by default for `global` and `operator_kms` wrap methods; customer-tunable in `[60, 3600]` seconds for those methods. **Exception: `customer_kms` (BYOK) tenants have a fixed 5-minute TTL** — this is not customer-tunable, and is intentional to bound plaintext exposure if the customer revokes KMS access. Two practical implications: * **First call after a cache miss is slow** (KMS round-trip). Subsequent calls are local. * **You can revoke KnoxCall's KMS access mid-flight**, and the next cache flush makes the tenant immediately unable to unwrap anything. The tenant goes "sealed." ## Sealed state If unwrapping fails (KMS access denied, KMS unreachable, key disabled), the manager records `tenant_kms_config.sealed_since`. For `customer_kms` (BYOK) tenants, the 5-minute cache TTL means the tenant becomes sealed within 5 minutes of losing KMS access. While sealed: * New encrypts / decrypts for that tenant fail with a clear error. * The cache still serves any unexpired entries — the tenant doesn't go dark instantly, only after the cache TTL. * The admin UI's Key Management section shows the sealed state and the last KMS error. The sealed state clears automatically on the next successful unwrap. ## Versioning Master keys are versioned per tenant. Each rotation creates a new row at the next version, marks the previous one `retired`, and sets the new one `active`. * **Active** — current version. New wraps use this. One per tenant. * **Retired** — older but still usable for unwrap of legacy ciphertext. * **Revoked** — explicitly destroyed. Cryptographic erasure: data wrapped by this version is **permanently unreadable**. ## Cryptographic erasure (the kill switch) Revoking a tenant master key version is destructive. It's the right-to-be-forgotten primitive, and it cascades: ```text theme={"dark"} Revoke tenant master key v1 ↓ Every CA private key wrapped by v1 → unreadable Every secret blob wrapped by v1 → unreadable Every transit-key version wrapped by v1 → unreadable Every database admin password wrapped by v1 → unreadable ``` The data **rows** stay in the database for audit, but the wrapped blobs can never be unwrapped again. Re-issuing a tenant master key won't bring them back — fundamentally different bytes. The admin UI guards revocation with a confirm-by-typing-tenant-name dialog. The audit-log entry records who, when, and why. ## What never leaves KnoxCall These never appear in any API response: * The unwrapped master key bytes. * The wrapped blob (visible in DB rows; not exposed to API). * The KMS provider's response payload. The Key Management endpoints return **metadata only** — version, wrap method, KMS key reference (the ARN, not the key), sealed state, timestamps. There is no `GET /admin/key-management/keys/{version}/material` and there never will be. ## Audit-trail markers | Audit action | What it means | | - | - | | `tenant_master_key.create` | New master key minted (rotation or initial provision) | | `tenant_master_key.kms_config_update` | KMS config changed (e.g. switched from `global` to `customer_kms_aws`) | | `tenant_master_key.revoke` | Cryptographic erasure of a version | | `tenant_master_key.cache_clear` | Operator forced an unwrap on next access (e.g. to verify KMS still works) | | `byok.kms.onboard` | Customer KMS provider onboarded (BYOK) | | `byok.kms.rotate` | Tenant master key rotated under BYOK config | | `byok.kms.unseal` | Tenant unsealed after KMS access restored (seal itself is not logged — only `sealed_since` is set on the config row) | ## What's shipped today * ✅ Per-tenant master keys for all three wrap methods (rows, manager, cache, sealed-state). * ✅ `global` wrap (the deployment-wide `MASTER_KEY_B64`). * ✅ AWS KMS provider (`operator_kms_aws`, `customer_kms_aws`). * ✅ GCP Cloud KMS provider (`operator_kms_gcp`, `customer_kms_gcp`) — Workload Identity Federation. * ✅ Azure Key Vault provider (`operator_kms_azure`, `customer_kms_azure`) — Federated Credentials. * ✅ Cryptographic erasure with audit trail. * ✅ Tenant KMS dashboard — manage BYOK config at Settings → Security → Tenant Master Key → Customer KMS. * ⏳ Bulk rewrap migration: when you rotate a master key, existing data stays wrapped under the retired version. A migration tool to rewrap everything to the new active version is on the roadmap (the manual escape valve is to do it via `rewrap` / per-resource update). ## See also * [BYOK & KMS providers →](/essentials/key-management/byok-and-kms) * [Crypto Keys overview →](/essentials/crypto-keys/crypto-keys-overview) — what `cloud-only` vs `bundled` means in the context of the master key * [Certificate Authority overview →](/essentials/pki/pki-overview) — how the master key protects CA private keys # Agent database proxy protocols Source: https://docs.knoxcall.com/essentials/migrations/agent-db-proxy Technical reference for the KnoxCall agent's localhost database proxy — supported protocols, peer attestation, TLS handling, and wire-level details. # Agent database proxy protocols The KnoxCall agent's database proxy binds one TCP listener per configured route on `127.0.0.1`. Applications connect as if they're connecting to the database directly. The agent handles authentication upstream and pipes the connection through. This page covers the wire-level details for each supported protocol. ## General architecture ```text theme={"dark"} Application (any process on the same host) ↓ TCP to 127.0.0.1:{port} Agent listener ↓ SO_PEERCRED UID check ↓ Fetch credentials from KnoxCall session bundle ↓ TLS connect to upstream DB host:port ↓ Perform auth handshake on behalf of application ↓ Pipe post-auth bytes bidirectionally until connection closes Upstream database ``` ## Local-only binding guarantee The agent **refuses to start** if any proxy port is configured to bind on a non-loopback interface. There is no override flag. If your deployment requires cross-host access, use a network namespace or a dedicated proxy tier — do not configure the agent to bind to `0.0.0.0`. For container deployments, run the agent in the same network namespace as the application (sidecar pattern). The `127.0.0.1` binding then scopes to the shared namespace. ## Peer attestation On Linux, the agent checks `SO_PEERCRED` on every new connection to retrieve the connecting process's UID. If a UID allowlist is configured on the route and the connecting UID is not in the list, the connection is refused immediately before any bytes are read. On Windows, named pipes with an explicit ACL replace `SO_PEERCRED`. The behaviour is equivalent. If no UID allowlist is configured, any process on localhost can connect. Configure a UID allowlist in production environments where multiple users share the same host. ## PostgreSQL **Auth method**: ScramSHA-256 1. Agent receives the client's startup packet (protocol 3.0) 2. Agent stores the requested database name and user field from the startup packet 3. Agent opens TLS to the upstream PostgreSQL server 4. Agent performs a full ScramSHA-256 handshake with the upstream server using KnoxCall credentials 5. After `AuthenticationOk`, agent sends `ReadyForQuery` back to the application 6. From this point, agent pipes bytes bidirectionally — no further inspection The application's own startup packet parameters (database, user, application\_name) are forwarded to upstream where safe. The password field in `PasswordMessage` is never forwarded — the agent substitutes its own credentials. **TLS modes**: | Mode | Description | | - | - | | `require` | Strict TLS — upstream cert validated against system CA bundle + any custom CAs in KnoxCall | | `prefer` (default) | Try TLS first; fall back to plain TCP if the upstream doesn't support it. Cert not validated. | | `disable` | Plain TCP — no TLS to upstream | ## MySQL **Auth method**: `CLIENT_PLUGIN_AUTH` with `caching_sha2_password` or `mysql_native_password` 1. Agent receives the client's connection request (no auth info — MySQL initiates auth from server side) 2. Agent opens TLS to upstream MySQL 3. Agent responds to the server's auth challenge using KnoxCall credentials 4. After server sends `OK_Packet`, agent pipes bidirectionally The agent negotiates `CLIENT_SSL` with the upstream server regardless of the application-to-agent TLS setting. The application always connects to `127.0.0.1` unencrypted; the agent always connects to upstream encrypted. ## MongoDB **Auth method**: SCRAM-SHA-256 via `OP_MSG` 1. Agent receives the client's `isMaster` / `hello` command 2. Agent opens TLS to upstream MongoDB 3. Agent performs SASL SCRAM-SHA-256 authentication using KnoxCall credentials 4. After auth succeeds, agent pipes all subsequent `OP_MSG` frames bidirectionally MongoDB connection strings in the application: `mongodb://127.0.0.1:{port}/{dbname}` — no username or password in the URI. For MongoDB Atlas clusters, the upstream address is the SRV hostname. The agent resolves SRV records and connects to the primary. ## Redis **Auth method**: `AUTH` command injection 1. Agent accepts the TCP connection from the application 2. Agent opens TLS to upstream Redis 3. Before forwarding any application commands, agent sends `AUTH username password` (Redis 6+ ACL) or `AUTH password` (Redis 5 and below) using KnoxCall credentials 4. After `+OK`, agent pipes all subsequent RESP frames bidirectionally The application does not send any `AUTH` command — the agent has already authenticated. If the application sends an `AUTH` command, the agent intercepts it and returns `+OK` without forwarding (to prevent auth token leakage). For Redis Cluster, the agent connects to one seed node and supports `MOVED` / `ASK` redirects transparently. ## TLS to upstream The agent always connects to the upstream database over TLS, regardless of how the application connects to the agent. Certificate validation behaviour: * **strict** (default): certificate chain validated against the system CA bundle plus any custom CAs stored in KnoxCall PKI * **skip\_verify**: validation disabled — only use for local development databases with self-signed certificates * **mtls**: agent presents a client certificate (stored as a KnoxCall certificate secret) during the TLS handshake ## Telemetry The agent emits `migration.db_proxy_probe` telemetry events to KnoxCall during the verification window. Each probe reports whether the upstream database is still being reached through the legacy credentials: | `event_type` | Probe type values | `outcome` values | | - | - | - | | `migration.db_proxy_probe` | `dns_probe`, `tls_probe`, `connection_attempt`, `no_traffic_window` | `still_reachable`, `nxdomain`, `connection_refused`, `tls_error`, `clean`, `unknown` | Probe events are stored in the migration verification log (visible on the Migration Verification page) and inform the safe-to-delete verdict. They do not appear in the main Audit Logs. Setting up DB proxy routes for a migration Probe results and safe-to-delete confirmation Agent installation and configuration Store client certs for mTLS upstream connections # Bucket A: HTTPS API migration Source: https://docs.knoxcall.com/essentials/migrations/bucket-a-https Zero-plaintext cutover for applications that call cloud secret store APIs directly. The agent intercepts, rewrites auth, and serves secrets from KnoxCall — no code changes required. # Bucket A: HTTPS API migration Bucket A handles migrations where your applications call a cloud secret store API (AWS Secrets Manager, Azure Key Vault, GCP Secret Manager, AWS SSM) directly over HTTPS. The KnoxCall agent intercepts these calls before they leave the host, rewrites the authentication header, and returns the secret from KnoxCall instead. **Your application code does not change.** ## How it works ```text theme={"dark"} Application ↓ HTTPS call to secretsmanager.us-east-1.amazonaws.com KnoxCall agent (intercept mode) ↓ Validates app identity (SO_PEERCRED or process UID) ↓ Rewrites auth header: AWS SigV4 → KnoxCall Bearer token ↓ Forwards to KnoxCall proxy KnoxCall proxy ↓ Injects real secret value ↓ Returns response in AWS SM wire format Application receives response — identical to a real AWS SM response ``` The agent acts as a transparent MITM on the loopback interface. The application's AWS SDK believes it's talking to AWS Secrets Manager; the agent translates the call to KnoxCall's API and returns a response in the expected format. No plaintext secret value ever touches disk. The agent holds secrets in memory only for the duration of the intercept. ## Supported capture surfaces | Surface | How interception works | | - | - | | **K8s CSI volume driver** | Agent sidecar intercepts filesystem reads from the CSI mount | | **External Secrets Operator (ESO)** | Agent intercepts ESO's API calls to the provider | | **ECS / Lambda environment injection** | Agent intercepts metadata endpoint calls at runtime | | **Cloud sidecar agents** | Agent intercepts AWS AppConfig, Azure App Configuration, GCP Config Connector calls | | **TLS interception** | Agent proxies all outbound TLS on configured ports; matches secret store hostnames | ## Setting up intercept rules Intercept rules tell the agent which outbound requests to intercept and how to rewrite them. ### Via admin UI 1. Go to **Infrastructure → Secret Store Migrations** 2. Open the migration → click the **Intercept Rules** tab 3. Click **Add rule** 4. Configure: * **Match host**: e.g. `secretsmanager.us-east-1.amazonaws.com` * **Match path prefix**: e.g. `/` * **Rewrite**: select the KnoxCall secret to substitute 5. Save — the agent picks up new rules within 30 seconds ### Example rule (AWS SM → KnoxCall) ```json theme={"dark"} { "match_host": "secretsmanager.us-east-1.amazonaws.com", "match_path_prefix": "/", "rewrite_secret_id": "sec_01abc...", "response_format": "aws_secrets_manager" } ``` The `response_format` field tells the agent to return the value in the format the application expects (AWS SM JSON envelope, plain value, Azure KV JSON, etc.). ## Zero-plaintext guarantee * The secret value is never written to disk, logged, or stored in the `migration_intercept_rules` table * The agent holds the value in memory only for the lifespan of a single request * If the agent process crashes mid-intercept, the application receives a connection error — not a partial or logged secret ## Monitoring migration progress The `secret_consumers` table tracks discovered workloads that are consuming each migrated secret: ```bash theme={"dark"} curl https://api.knoxcall.com/admin/migrations/mgr_01abc.../verification-events \ -H "Authorization: Bearer $KC_ADMIN_JWT" ``` The admin UI shows a consumer count per secret, and highlights any consumers not yet seen through the intercept path. ## Migration status progression | Status | What it means | | - | - | | `pending` | Migration created; discovery not yet started | | `discovering` | Agent scanning workloads; no interception active yet | | `awaiting_review` | Discovery complete; items queued for reviewer decisions | | `completed` | All approved items committed to KnoxCall secrets | ## Cutover When the verification dashboard shows all consumers green and all items have been approved: 1. In the admin UI, click **Commit migration** (or call `POST /admin/migrations/:id/commit`) 2. Wait for the `completed` status 3. You can now safely revoke cloud store credentials and remove secrets from the source provider Do not revoke the cloud store credentials until you have confirmed all application instances are routing through KnoxCall. Intercept rules route live traffic — removing them before cutover confirmation will break applications. ## Cancelling a Bucket A migration You can cancel a migration in `pending`, `discovering`, or `awaiting_review` state: ```bash theme={"dark"} curl -X POST https://api.knoxcall.com/admin/migrations/mgr_01abc.../cancel \ -H "Authorization: Bearer $KC_ADMIN_JWT" ``` This removes all intercept rules. Applications revert to direct cloud store access immediately. For database credentials DNS/TLS probes and safe-to-delete confirmation Intercept rules and migration endpoints Back to overview # Bucket B: Database migration Source: https://docs.knoxcall.com/essentials/migrations/bucket-b-databases Migrate database credentials from cloud secret stores via the KnoxCall agent's localhost TCP proxy. Change one connection string, nothing else. # Bucket B: Database migration Bucket B handles database credentials stored in a cloud secret store. The KnoxCall agent binds a local TCP listener on `127.0.0.1`. Your application connects to `127.0.0.1:{agent_port}` — the agent authenticates to the real database using KnoxCall-managed credentials, then pipes the connection through bidirectionally. **The only application change: update the database connection string.** No credential rotation, no SDK changes, no code changes beyond the host and port. ## How it works ```text theme={"dark"} Application ↓ TCP connection to 127.0.0.1:5432 (agent listener) KnoxCall agent ↓ Validates connecting process UID (SO_PEERCRED) ↓ Fetches DB credentials from KnoxCall ↓ Opens TLS connection to upstream DB (db.prod.internal:5432) ↓ Performs authentication on behalf of the application ↓ Pipes post-auth bytes bidirectionally Upstream database ← TLS ← Agent ← plaintext ← Application ``` The application never sees the database password. The agent handles the entire authentication handshake. The agent **only ever binds `127.0.0.1`**, never `0.0.0.0`. If you configure a proxy route with a non-loopback bind address, the agent refuses to start. This is enforced — not advisory. ## Supported protocols | Protocol | Default port range | Notes | | - | - | - | | PostgreSQL | 5432–5440 | Full wire protocol; ScramSHA-256 upstream auth | | MySQL | 3306–3316 | Full wire protocol; CLIENT\_PLUGIN\_AUTH | | MongoDB | 27017–27027 | OP\_MSG with SCRAM auth | | Redis | 6379–6389 | RESP protocol; AUTH command injection | Port ranges are configurable per route. Each database gets its own port on the agent. ## Application change required Update the connection string in your application's configuration: ```diff theme={"dark"} - DATABASE_URL=postgresql://db.prod.internal:5432/mydb + DATABASE_URL=postgresql://127.0.0.1:5432/mydb ``` No username or password needed in the connection string — the agent provides them. Most database drivers support passwordless connections when the server doesn't challenge (the agent handles the challenge upstream). For connection poolers like PgBouncer or ProxySQL running in front of the database, update the pooler's upstream to `127.0.0.1:{agent_port}` instead. The application points at the pooler as usual. ## Setting up a DB proxy route ### Via admin UI 1. Go to **Infrastructure → Secret Store Migrations** 2. Open the migration → click the **DB Proxy Routes** tab 3. Click **Add route** 4. Configure: * **Protocol**: PostgreSQL / MySQL / MongoDB / Redis * **Local port**: the port the agent will bind on `127.0.0.1` * **Upstream host**: your real database hostname * **Upstream port**: real database port * **Credentials secret**: the KnoxCall secret holding the database username/password * **UID allowlist** (optional): Linux UIDs permitted to connect to this proxy port ### Via API ```bash theme={"dark"} curl -X POST https://api.knoxcall.com/admin/migrations/mgr_01abc.../db-proxy-routes \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "Content-Type: application/json" \ -d '{ "protocol": "postgres", "local_port": 5432, "upstream_host": "db.prod.internal", "upstream_port": 5432, "committed_secret_id": "sec_01abc...", "allowed_uids": [1000, 1001] }' ``` ## Security model ### Peer attestation The agent validates the UID of every connecting process via `SO_PEERCRED` on Linux (or named-pipe ACL on Windows). If the connecting process UID is not in the allowlist, the connection is refused before any data is exchanged. ### TLS to upstream The agent always opens TLS to the upstream database — even when the application connects to the agent over unencrypted localhost. The agent strictly validates the upstream certificate by default. For development environments where the DB has a self-signed cert, TLS verification can be relaxed per route (not recommended for production). ### mTLS If the upstream database requires client certificates for mutual TLS, store the client certificate as a KnoxCall certificate secret and reference it in the route config. The agent presents the certificate automatically during the TLS handshake. ## Verification and cutover Once your application is routing through the agent proxy: 1. The [migration verification dashboard](/essentials/migrations/verification) tracks probe results showing the old-provider endpoint becoming unreachable 2. When all probes pass and consumers are confirmed, click **Confirm migration complete** 3. The "safe to delete cloud secrets" banner appears At that point, the database credentials in the cloud secret store can be rotated or deleted. ## Cancelling a Bucket B migration Cancelling removes all DB proxy routes. Applications that were connecting through the agent will lose database connectivity immediately. Only cancel if you're reverting the migration entirely. ```bash theme={"dark"} curl -X POST https://api.knoxcall.com/admin/migrations/mgr_01abc.../cancel \ -H "Authorization: Bearer $KC_ADMIN_JWT" ``` Wire-level details: ScramSHA-256, SCRAM, AUTH injection Probe results and safe-to-delete confirmation For HTTP-based secret store consumers DB proxy route endpoints # Secret Store Migrations overview Source: https://docs.knoxcall.com/essentials/migrations/overview Migrate secrets from AWS Secrets Manager, Azure Key Vault, or GCP Secret Manager into KnoxCall with zero downtime and zero plaintext exposure. # Secret Store Migrations overview If your secrets currently live in AWS Secrets Manager, AWS SSM Parameter Store, Azure Key Vault, or GCP Secret Manager, the Secret Store Migration feature lets you move them into KnoxCall — without changing application code and without any secrets ever touching disk during the migration. The goal: KnoxCall becomes the single place your team manages, audits, and rotates credentials, regardless of which cloud provider currently holds them. ## How it works The KnoxCall agent runs alongside your applications. During a migration, the agent acts as an intermediary: * **Bucket A (HTTPS APIs)**: the agent intercepts API calls your application makes to the cloud secret store, rewrites the authentication, and forwards the secret from KnoxCall instead. No code change in your app. * **Bucket B (Databases)**: the agent binds a local TCP port on `127.0.0.1`. Your application connects to `127.0.0.1:{port}` instead of the database host directly. The agent handles all authentication to the real database and pipes the connection through. | | Bucket A — HTTPS APIs | Bucket B — Databases | | - | - | - | | **Mechanism** | Agent TLS intercept + auth header rewrite | Agent TCP proxy (localhost listener) | | **Code changes required** | None | Change connection string to `127.0.0.1:{port}` | | **Source stores** | AWS SM, AWS SSM, Azure KV, GCP SM | Any database with credentials in those stores | | **Protocols** | HTTPS | PostgreSQL, MySQL, MongoDB, Redis | | **Plaintext exposure** | Never | Never | ## Migration phases Every migration moves through this lifecycle: ```text theme={"dark"} pending → discovering → awaiting_review → completed ``` | Status | What's happening | | - | - | | `pending` | Migration created; discovery job not yet started | | `discovering` | Agent or pull worker scanning the secret store for credentials | | `awaiting_review` | Discovery complete; items are queued for reviewer decisions (approve / reject) | | `completed` | All approved items written to KnoxCall secrets; migration closed | | `cancelled` | Migration cancelled by operator | You can cancel a migration in the `pending`, `discovering`, or `awaiting_review` phase. Once `completed`, the migration is terminal. ## Source stores supported | Provider | API secrets | DB credentials | | - | - | - | | AWS Secrets Manager | ✅ Bucket A | ✅ Bucket B | | AWS SSM Parameter Store | ✅ Bucket A | — | | Azure Key Vault | ✅ Bucket A | ✅ Bucket B | | GCP Secret Manager | ✅ Bucket A | ✅ Bucket B | ## Who can review a migration Revealing an imported item's value, approving or rejecting an item, and committing a batch each need **both** of these: * **The migration-review delegation.** The Owner always holds it; an Admin holds it only if the Owner granted it. Nobody else can review. * **The reveal chokepoint, Class B.** Owner or Admin by role, and **one** fresh second-factor verification per action, made for the migration review (`"action_scope": "secret.migration"`; a verification made to reveal a secret does not count here, and one made here counts nowhere else). Each action writes a `plaintext.reveal` audit entry before anything is decrypted or changed, and counts against the per-person reveal budget (60 per 5 minutes). If the audit entry cannot be written or the budget cannot be counted, the action is refused. Revealing an item still also writes its own critical audit entry and emails the Owner when someone else reveals it. ## Quick start ### Via admin UI 1. Go to **Infrastructure → Secret Store Migrations** 2. Click **New Migration** 3. Choose your source provider and bucket type 4. Follow the setup wizard ### Via API ```bash theme={"dark"} curl -X POST https://api.knoxcall.com/admin/migrations \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "Content-Type: application/json" \ -d '{ "provider": "aws_sm", "mode": "pull", "credentials": { "role_arn": "arn:aws:iam::111122223333:role/KnoxCallMigrationReader", "external_id": "your-chosen-external-id" }, "scope": { "regions": ["us-east-1"] } }' ``` Provider values: `aws_sm`, `aws_ssm`, `azure_kv`, `gcp_sm`. Mode values: `pull`, `capture`, `both`. Static credentials (`access_key_id`, `client_secret`, etc.) are rejected — use IAM role assumption or WIF. See the [Migrations API reference](/api-reference/migrations) for full field documentation per provider. Zero-plaintext cutover for HTTP/HTTPS secret store consumers Localhost TCP proxy for database credential migration Technical reference for PostgreSQL, MySQL, MongoDB, Redis wire protocols DNS/TLS probes, operator confirmation, and safe-to-delete status Full endpoint reference Install and configure the self-hosted proxy agent # Migration verification Source: https://docs.knoxcall.com/essentials/migrations/verification Run DNS and TLS probes to confirm old-provider endpoints are unreachable, review the timeline, and get the safe-to-delete banner before cutting over. # Migration verification Before KnoxCall marks a migration complete, verification confirms that: 1. Old-provider endpoints are becoming unreachable (your applications have stopped calling them) 2. All known consumers have been seen routing through KnoxCall 3. An operator has explicitly confirmed the migration is clean ## The verification dashboard Navigate to **Infrastructure → Secret Store Migrations → your migration → Verification tab**. The dashboard shows: * **Per-probe timeline** — a chronological list of DNS and TLS probe results, one row per probe * **Consumer status** — which workloads have been seen using the KnoxCall path vs still hitting the old provider * **Operator confirmation** — a button that unlocks once probes are green * **Safe-to-delete banner** — appears when all three conditions above are met ## DNS and TLS probes KnoxCall runs two probe types against each migrated secret's original provider endpoint: | Probe type | What it checks | "Pass" condition | | - | - | - | | **DNS** | DNS resolution of the old provider hostname (e.g. `secretsmanager.us-east-1.amazonaws.com`) | ✅ Resolves correctly (we want it to still resolve during migration) | | **TLS** | TLS handshake attempt to the old provider | ✅ Handshake succeeds (again — expected during migration phase) | Probes run every 5 minutes during an active migration. The verification dashboard uses the **absence** of consumer traffic to the old endpoint (not unreachability) as the primary signal — "pass" means the endpoint is still reachable but your apps are no longer hitting it. The probes don't check if your apps are calling the old endpoint — that tracking comes from the agent's `secret_consumers` table. Probes check reachability for operational awareness (e.g. if the cloud provider decommissions an API endpoint mid-migration). ## Operator confirmation Once all items have been reviewed and approved, and consumer status shows all known workloads routing through KnoxCall: 1. Click **Commit migration** in the admin UI 2. Or call the API: ```bash theme={"dark"} curl -X POST https://api.knoxcall.com/admin/migrations/mgr_01abc.../commit \ -H "Authorization: Bearer $KC_ADMIN_JWT" ``` This writes approved items as live KnoxCall secrets. The migration must be in `awaiting_review` status with at least one approved item. One request commits up to `batch_size` items (250); a larger migration stays in `awaiting_review` with a non-zero `remaining_count`, and you repeat the call — in the admin UI, click **Commit migration** again — until it reaches `0` and the status becomes `completed`. Response: ```json theme={"dark"} { "id": "mgr_01abc...", "status": "completed", "committed_count": 30, "remaining_count": 0, "batch_size": 250 } ``` ## Safe-to-delete guidance Once a migration reaches `completed` status, it is safe to clean up the source cloud store when all of the following hold: * All tracked consumers have been seen routing through KnoxCall at least once * Verification events show no probe errors in the last 24-hour window * The commit has succeeded (`status: "completed"`) At that point: * You can revoke the IAM/read permissions KnoxCall used to discover secrets from the cloud store * You can archive or delete the secrets from the original cloud store * Removing intercept rules or DB proxy routes at this point is safe ## Verification timeline API Fetch the full probe history: ```bash theme={"dark"} curl https://api.knoxcall.com/admin/migrations/mgr_01abc.../verification-events \ -H "Authorization: Bearer $KC_ADMIN_JWT" ``` Response: ```json theme={"dark"} { "migration_id": "mgr_01abc...", "safe_to_delete": false, "verified_at": null, "verification_window_days": 35, "events": [ { "id": "vev_01...", "secret_migration_item_id": null, "event_type": "tls_probe", "endpoint_url": "secretsmanager.us-east-1.amazonaws.com:443", "outcome": "still_reachable", "details": {}, "source": "agent", "created_at": "2026-05-25T10:25:00Z" }, { "id": "vev_02...", "secret_migration_item_id": null, "event_type": "dns_probe", "endpoint_url": "secretsmanager.us-east-1.amazonaws.com", "outcome": "still_reachable", "details": {}, "source": "agent", "created_at": "2026-05-25T10:25:00Z" } ] } ``` ## Troubleshooting **DNS probe shows `fail`:** * Cloud provider may have changed the endpoint hostname — check for provider announcements * If using a VPC endpoint, ensure the agent has DNS visibility to the VPC endpoint resolver **TLS probe shows `fail`:** * Certificate rotation at the cloud provider — usually transient; check if the next probe resolves * Network policy blocking agent outbound to the cloud store — check firewall / security group rules **Consumer still showing old provider after routing through KnoxCall:** * Some consumers may be long-running processes that cached the old connection — restart them * Check for consumers not covered by the intercept rules (path prefix mismatch) **Operator confirmation button greyed out:** * One or more consumers have not yet been seen through the KnoxCall path * Check the consumer list for any workloads in `pending` state and verify they're running and using the updated connection string (Bucket B) or within the intercept scope (Bucket A) HTTPS intercept setup and cutover Database proxy setup and cutover Verification events and confirm endpoints Back to overview # Certificate Authority overview Source: https://docs.knoxcall.com/essentials/pki/pki-overview Run a private CA inside KnoxCall. Issue short-lived TLS certs for mTLS, internal services, and IoT — without standing up your own PKI. # Certificate Authority overview KnoxCall's **Certificate Authority** lets each tenant run a private PKI: a long-lived **root** signs short-lived **intermediates**, and the intermediate signs **leaf** certificates you hand out to services. You get a clean trust chain you control without standing up `step-ca`, `cfssl`, or Vault PKI yourself. Use it for: * **mTLS between internal services** — every service gets a leaf, every TLS handshake authenticates both ends. * **Service identity** — short-lived certs are a stronger identity than long-lived API keys (a stolen cert dies on its own). * **IoT / device fleets** — issue a leaf per device, scope it via roles. * **Replacing manual certificate operations** — no more long-lived self-signed `.pem` files in `git`. ## The trust chain at a glance ```text theme={"dark"} Root CA (10 years) └── Intermediate CA (auto-rotated, e.g. 30 days) ├── Leaf cert api.example.com (e.g. 30 days) ├── Leaf cert worker.example.com └── Leaf cert iot-device-7af2 ``` * **Root** — long-lived (10 years by default). Signs **only** intermediates. Its private key is wrapped with your [tenant master key](/essentials/key-management/tenant-master-key), so cryptographic erasure of that master key permanently destroys the root. * **Intermediate** — short-lived. KnoxCall's lease manager auto-rotates it before expiry. There's only ever one active intermediate per root at a time. * **Leaf** — issued under a **role** that constrains what it can claim (allowed domains, max TTL, etc). You install the **root** in your trust store once. Everything below it validates automatically. ## Customer-facing flow 1. **Create a root** in the admin UI under **Certificate Authority → New Root**. Pick a mount name (`corp`, `iot`, `web`) and the Subject DN fields. KnoxCall auto-creates the first intermediate. 2. **Define a role** that scopes what leaves can be issued under this root — e.g. "only `*.internal.example.com`, max TTL 7 days". 3. **Issue a leaf** with `POST /admin/pki/roots/{name}/issue/{role}`. You get back the cert PEM + private key — **the private key is shown once and never stored**. 4. **Install the root cert** in clients' trust stores (`GET /admin/pki/roots/{name}/cert` returns the PEM). 5. **Revoke** any leaf if it leaks (`POST /admin/pki/roots/{name}/revoke`). The cert lands in the [CRL](#revocation--crls). ## What's stored | Object | Stored | Private key location | | - | - | - | | Root | Cert PEM + wrapped private key | KnoxCall, wrapped with tenant master key | | Intermediate | Cert PEM + wrapped private key | KnoxCall (cloud) or shipped to self-hosted agents in the session bundle | | Leaf | Cert PEM only | **Returned to caller once, never persisted** | If you lose a leaf's private key, you re-issue. KnoxCall doesn't have it. ## Algorithms * **v1**: ECDSA P-256 only (root, intermediate, and leaf). * **Roadmap**: RSA, EdDSA. The schema's `key_algorithm` column is enum-constrained so adding new algorithms is a server change, not a data migration. ## Issuance roles A **role** is the constraint template applied when a leaf is issued under it. The Vault equivalent is `pki/roles/`. * `allowed_domains` — glob list of CN/SAN values, e.g. `['*.example.com', 'api.internal']`. * `allow_subdomains` — whether `foo.example.com` is allowed when the role allows `example.com`. * `allow_wildcards` — whether the leaf itself can be a wildcard cert. * `max_ttl_seconds` / `default_ttl_seconds` — TTL bounds. Leaves are also capped at the **intermediate's** remaining validity, so a leaf can't outlive the intermediate that signed it. See [Roles & issuance](/essentials/pki/roles-and-issuance) for the rule matrix. ## Issuance paths | Path | Where SQL/signing runs | When to use | | - | - | - | | **Cloud** (`issued_via='cloud'`) | KnoxCall control plane signs the leaf | Default. Customers using cloud KnoxCall. | | **Agent** (`issued_via='agent'`) | A self-hosted KnoxCall agent signs the leaf locally using the intermediate's private key in its session bundle | Self-hosted deployments where the issuance call must stay inside the customer's network. | Both paths produce certs under the **same** root chain — clients in your trust store don't know or care which one signed. ## Revocation & CRLs * `POST /admin/pki/roots/{name}/revoke` with a `serial_hex` flips the cert's `revoked_at` timestamp. * `GET /admin/pki/roots/{name}/crl` returns a Certificate Revocation List your verifiers can poll. * **v1** emits a text CRL for inspection; binary DER CRL is a follow-up. Don't depend on the byte format yet for production verifiers. * Short TTLs (days, not years) mean revocation is mostly a "fast kill" tool — most leaves age out on their own before a CRL pull would catch them. ## Lease integration The intermediate is rotated by the [lease manager](/essentials/databases/leases) via the `pki_intermediate` lease type. You don't run a cron — KnoxCall mints the next intermediate before the current one expires, retires the old one, and (for self-hosted) ships the new one to agents in their next session bundle. If an intermediate ever expires without a successor, leaf issuance fails with a clear error. Inspect the `Active leases` panel on the Certificate Authority page to see when the next rotation will happen. ## Who can issue a certificate Issuing a leaf certificate hands you its private key, so it is a reveal. In the dashboard this is a **Class B reveal**: only an **Owner** or an **Admin** can do it (an Editor cannot, even with **Reveal secrets** turned on; that setting covers secret values, certificates and credentialed workflow tests only), and every use asks for a fresh second factor (an authenticator code, a passkey or a security key; an emailed code does not count). Each use writes a `plaintext.reveal` entry to the audit log **before** anything is shown, and counts against the same per-person reveal budget as showing a secret's value (60 per 5 minutes). If the audit entry cannot be written, or the budget cannot be counted, the request is refused rather than served. The same applies to issuing an mTLS client certificate from a client's **Credentials** section (**Issue a new cert**), which mints the certificate and its private key under your client-authentication CA; registering a certificate you generated yourself by its thumbprint hands nothing back and needs neither. On the Management API (`/v1`) a credential still needs the matching permission, and a token issued to a person never carries `pki:issue`. Creating roots and roles, revoking and downloading the CRL are unchanged. ## Next steps * [Roles & issuance →](/essentials/pki/roles-and-issuance) * [Tenant master key →](/essentials/key-management/tenant-master-key) — what cryptographic erasure of a tenant master key does to your CA * [API reference →](/api-reference/overview) # Roles and issuance Source: https://docs.knoxcall.com/essentials/pki/roles-and-issuance How KnoxCall constrains what each leaf cert can claim. Allowed domains, wildcards, TTLs, and the validation rules applied at issuance time. # Roles and issuance A **role** is the issuance template applied when you mint a leaf cert under a root. It answers: "what's a leaf allowed to claim, and for how long?" The role is what enforces the difference between a CA that hands out anything and a CA that's safe to delegate. ## What a role controls | Field | What it does | | - | - | | `allowed_domains` | Glob list of CN / SAN values the leaf may claim. `['*.example.com', 'api.internal']` — only these. | | `allow_subdomains` | If `true`, `*.allowed-domain` (e.g. `foo.bar.example.com` under `*.example.com`) is permitted. If `false`, only exact matches. | | `allow_wildcards` | If `true`, the leaf itself can be a wildcard cert (`*.example.com`). If `false`, only concrete hostnames. | | `default_ttl_seconds` | Default validity when the caller doesn't specify. Default 30 days. | | `max_ttl_seconds` | Hard ceiling per-issue. Default 90 days. | | `key_algorithm` | Forced to `ecdsa-p256` in v1. | The maximum effective TTL is `min(role.max_ttl, intermediate.not_after - now)`. A leaf can't outlive the intermediate that signed it. ## Creating a role ```bash theme={"dark"} curl -X POST https://api.knoxcall.com/admin/pki/roots/corp/roles \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "X-Tenant-ID: $TENANT_ID" \ -H "Content-Type: application/json" \ -d '{ "role_name": "internal-services", "allowed_domains": ["*.internal.example.com", "*.svc.cluster.local"], "allow_subdomains": true, "allow_wildcards": false, "max_ttl_seconds": 604800, "default_ttl_seconds": 86400 }' ``` This says: leaves under `internal-services` can claim any host under `*.internal.example.com` or `*.svc.cluster.local`, can't be wildcards themselves, and live at most 7 days (24h by default). ## Issuing a leaf ```bash theme={"dark"} curl -X POST https://api.knoxcall.com/admin/pki/roots/corp/issue/internal-services \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "X-Tenant-ID: $TENANT_ID" \ -H "Content-Type: application/json" \ -d '{ "subject": { "common_name": "auth-svc.internal.example.com" }, "san_dns": ["auth-svc.internal.example.com", "auth-svc.svc.cluster.local"], "ttl_seconds": 86400 }' # Response: # { # "serial_hex": "...", # "cert_pem": "-----BEGIN CERTIFICATE-----...", # "private_key_pem": "-----BEGIN EC PRIVATE KEY-----...", # "ca_chain_pem": "...", # "not_after": "2026-04-27T..." # } ``` The `private_key_pem` is **shown once**. KnoxCall doesn't keep it. Save it next to the cert in whatever your service expects (Kubernetes secret, file mount, etc) — you can't fetch it again. The `ca_chain_pem` includes the intermediate so clients can validate without a separate fetch. ## Subject DN inheritance Leaves inherit org-level Subject DN fields (Organization, Country, Province, Locality) from the root if you don't supply them on the issue call. CN and SAN come from the request. This keeps "issued by Acme Inc, in California" consistent across every leaf without repeating it on every call. You can override any field per-issue if you really need to. ## Validation rules at issue time KnoxCall rejects an issuance request when: * The CN or any SAN doesn't match `allowed_domains` (subject to the `allow_subdomains` / `allow_wildcards` flags). * The requested TTL exceeds `role.max_ttl_seconds`. * The requested TTL would push `not_after` past the intermediate's expiry. (Effective ceiling is `min(role.max_ttl, intermediate.not_after - now)`.) * The intermediate is `retired` and no successor has been minted yet — surface as a `409` so the caller can retry. ## Revocation To revoke a leaf: ```bash theme={"dark"} curl -X POST https://api.knoxcall.com/admin/pki/roots/corp/revoke \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "X-Tenant-ID: $TENANT_ID" \ -H "Content-Type: application/json" \ -d '{ "serial_hex": "", "reason": "key_leaked" }' ``` The cert lands in the CRL (`GET /admin/pki/roots/corp/crl`). With short TTLs, most certs age out before a CRL pull catches them — revocation is a fast kill switch, not the primary expiration mechanism. ## What KnoxCall doesn't do (yet) * **OCSP responder** — only CRLs at v1. * **DER-encoded CRL** — text format only at v1; consumers that strictly require DER need a follow-up. * **Non-EC algorithms** — RSA / Ed25519 are roadmap. * **CSR-based issuance** — the keypair is generated server-side and the private key is returned to the caller. CSR support is on the list. ## Quick rules of thumb * Pick **short** default TTLs (24–48h). Issuance is cheap; renewal is a `curl`. * Use **separate roots** for different trust domains (`corp` vs `iot` vs `web`). They have separate root certs, so a leak in one doesn't compromise the others. * Use **separate roles** within a root for different scopes — e.g. `read-only-services` (allow\_subdomains=false) vs `infra-services` (allow\_subdomains=true). * The customer's trust store only ever needs the **root** PEM. Re-installing it across the fleet is the expensive operation; rotating intermediates and leaves is invisible. ## See also * [Certificate Authority overview →](/essentials/pki/pki-overview) * [API reference →](/api-reference/overview) # Ephemeral Proxy Source: https://docs.knoxcall.com/essentials/proxy/ephemeral-proxy One-shot proxy for sensitive requests. Resolve vault tokens on the wire so raw PII and secrets do not have to flow through your application. # Ephemeral Proxy The **Ephemeral Proxy** is a one-shot HTTPS proxy. Unlike [Routes](/essentials/routes/what-are-routes), there is no pre-configured row — every call is its own invocation. You pass the upstream URL in a header, KnoxCall validates it, swaps any `{{ token: ... }}` references for the underlying vault values, and forwards the request. The composition with [Vaults](/essentials/secrets/secrets-overview) is the killer feature: **store the token in your database, send the token to KnoxCall, KnoxCall sends the raw value to the upstream.** On this path the raw SSN, bank detail or API secret is never returned to your systems. Scope of that guarantee: the proxy never hands the value back to you, but the same credential that can detokenize on the proxy path can also read the value outright via `GET /v1/vaults/:vault/tokens/:token`. The control is about where values *flow*, not a ceiling on what a detokenize-capable credential can reach. Binding a vault to allowed destinations, and refusing the plain read for card-shaped vaults, is planned work. ## When to use it Use Ephemeral Proxy when: * The destination URL is dynamic — chosen per-request, not per-route. * You want to detokenize vault tokens last-mile (PII handling, secret injection). * You need a quick passthrough without configuring a Route, ApiKey scope, environment overrides, etc. Use [Routes](/essentials/routes/what-are-routes) when: * You call the same upstream repeatedly and want signing, rate limits, env overrides, and full per-request observability. * You need IP allowlisting, mTLS, or method-specific configuration. * You want logs grouped by route in the dashboard. ## How a request flows ```text theme={"dark"} Your client → POST https://api.knoxcall.com/v1/proxy X-Knox-Proxy-URL: https://api.stripe.com/v1/charges Authorization: Bearer kc_live_... Content-Type: application/json { "amount": 5000, "currency": "usd", "source": "{{ token: tok_01H... }}" } KnoxCall: 1. Authenticates your OAuth access token (or legacy API key) 2. Checks `proxy:invoke` permission + your plan limit 3. Validates the upstream URL (https only, DNS hostname, SSRF-safe) 4. Scans body + query for {{ token: ... }} references 5. Looks up each token in your vaults, gates per-vault detokenize 6. Renders the body with raw values substituted in 7. Sends the rewritten request to the upstream 8. Streams the response back to you, audit-logged Stripe sees: { "amount": 5000, "currency": "usd", "source": "tok_visa_real_card_token" } You see: Stripe's response, passed through unchanged. ``` The token strings, raw values, and bodies are **never** logged. Only metadata (upstream host, status code, latency, token count) lands in the audit chain and the Fleet Graph. ## Quick example ```bash theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) curl -X POST https://api.knoxcall.com/v1/proxy \ -H "Authorization: Bearer $TOKEN" \ -H "X-Knox-Proxy-URL: https://api.stripe.com/v1/charges" \ -H "Content-Type: application/json" \ -d '{ "amount": 5000, "currency": "usd", "source": "{{ token: tok_01HZX... }}" }' ``` The `tok_01HZX...` came from a prior `POST /v1/vaults/.../tokenize` call — see the [Vaults overview](/essentials/secrets/secrets-overview). ## Template syntax Two reference forms are supported in the JSON body and query string: | Form | What it does | | - | - | | `{{ token: tok_xxx }}` | Looks up the token in your tenant's vaults and substitutes the raw value. | | `{{ encrypted \| json: $.path }}` | Reads a value from the optional `X-Knox-Encrypted` header payload (base64-encoded JSON in v1) using JSONPath. | | `\{{ ... }}` | Escape — renders a literal `{{ ... }}`, never resolved. Use it when the braces are meant for the upstream's own templating. | Templates are scanned in body and query only. Headers pass through unchanged in v1 — header-injection is a separate hardening pass. If a token isn't found, KnoxCall returns `400 token_not_found` rather than silently sending an empty value to the upstream. Anything else brace-shaped that isn't one of the forms above — `{{ secret: x }}`, `{{ token: }}`, a mis-cased `{{ TOKEN: ... }}` — is likewise refused with `400 template_error` rather than forwarded verbatim, so an unresolved template can never reach the upstream in place of a credential. ## Required and optional headers | Header | Required | Purpose | | - | - | - | | `Authorization: Bearer ` | Yes | Standard KnoxCall API auth — an OAuth access token (`kc_…`) or legacy API key (`tk_…`). | | `X-Knox-Proxy-URL` | Yes | The upstream URL. Must be `https://`, must be a DNS hostname (no IP literals), must pass SSRF checks. | | `Content-Type` | Recommended | Defaults to `application/json` if omitted. | | `X-Knox-Encrypted` | No | Base64-encoded JSON payload addressable via `{{ encrypted \| json: ... }}`. v1 is opaque base64-JSON; full JWE support is on the roadmap. | | `X-Knox-Timeout-Ms` | No | Per-request upstream timeout (positive integer ≤ 30000). | Reserved headers (`Authorization`, `Cookie`, `Host`, `Content-Length`, anything starting with `X-Knox-`) are stripped before the request reaches the upstream, and so is `X-Api-Key` when it holds a KnoxCall credential (a value beginning `tk_`, `kc_`, `kr_` or `AKE`) — that is the key you authenticated *to KnoxCall* with, and forwarding it would put a key granting your whole management API into your upstream's access logs. An `X-Api-Key` carrying your **upstream's** own key (`sk-ant-…`, and so on) is forwarded unchanged, so providers that authenticate with that header keep working. The headers KnoxCall reads itself and the headers a proxy in front of KnoxCall adds are not forwarded either: `X-KnoxCall-Key`, `X-KnoxCall-Route`, `X-KnoxCall-Environment`, `X-Forwarded-For`, `X-Real-IP`, `True-Client-IP`, `CF-Connecting-IP` and the rest of the exact names in [Request headers KnoxCall does not forward](/api-reference/request-headers). Those are matched by exact name, not by prefix. Every other header you send is forwarded unchanged, including a `CF-Access-Client-Id` / `CF-Access-Client-Secret` pair for an upstream behind Cloudflare Access. ## What you get back KnoxCall passes the upstream response through verbatim — same status code, same headers (minus hop-by-hop), same body bytes. The one exception is a request that detokenized a card number; see [Responses are re-tokenized](#responses-are-re-tokenized) below. KnoxCall adds two headers so you can correlate: | Response header | Meaning | | - | - | | `X-Knox-Request-Id` | The KnoxCall request ID for audit-log lookup. | | `X-Knox-Destination-Status` | The upstream's status code (echoed; useful when proxies/CDNs mangle the surfaced status). | If the upstream is unreachable, KnoxCall returns `502 upstream_error`. ### Responses are re-tokenized If your request detokenized a token from a **`pan`** vault, KnoxCall scans the upstream's reply for that exact card number and puts the token back before the response reaches you. Many processors echo the card number in their confirmation object, in a `Location` redirect or in a plain-text receipt — without this, the number they echoed would land in your backend, your logs and your database, which is precisely what tokenization exists to prevent. What is replaced, and what is not: * **Only the values this request opened**, matched exactly. A different card number the processor happens to mention is your own data and is left alone; this is not a general card scan of your responses. * **Only `pan` vaults.** A value from a `generic`, `ssn` or `email` vault echoed back is untouched. * **However the processor spelled it.** `4111111111111111`, `4111 1111 1111 1111`, `4111-1111-1111-1111` and the form-encoded `4111+1111+1111+1111` / `4111%201111%201111%201111` all match. * **Body and headers.** JSON fields (at any depth, including object keys and values sent as bare JSON numbers), form-encoded values, text bodies, and header values such as `Location`. A header value is *replaced*, never stripped, so a 3-D Secure redirect keeps working. Two consequences to plan for: * **A rewritten body is returned uncompressed.** KnoxCall sends `Accept-Encoding: identity` upstream on any request that detokenizes a `pan` token, because a compressed reply cannot be scanned. If the upstream ignores that and compresses anyway, the response is withheld with `502 response_not_retokenizable` rather than forwarded unscanned. The same answer is given for a binary body. * **Streaming is refused for these requests.** A card number can straddle any chunk boundary the upstream picks, so a streamed reply cannot be re-tokenized honestly. A request that both detokenizes a `pan` token and asks for streaming (`Accept: text/event-stream`, or `"stream": true` in the body) is answered `409 pan_streaming_unsupported`. Send the card request without streaming. Every response KnoxCall re-tokenizes writes a `vault.retokenize` audit entry naming the vault and how many occurrences were replaced — never the number and never the token. A non-zero count is worth acting on: it tells you your processor is echoing card data back. ### When a referenced token cannot be resolved A `{{ token: … }}` that KnoxCall cannot exchange for its value **stops the request before the upstream is called** — nothing is sent with the hole unfilled. The status says whether retrying can help: | Status | Code | What happened | | - | - | - | | `400` | `token_not_found` | No token of that string exists in this tenant and mode. | | `400` | `token_expired` | The token exists and has passed its TTL. | | `400` | `validation_error` | A token *id* (a UUID) was passed where the token string belongs. | | `402` | `plan_limit` | The plan's vault-operation allowance is used up. | | `403` | `vault_destination_refused` | The vault will not release the value to this upstream host — it is not on the vault's `allowed_destinations`. | | `409` | `vault_disabled` | The vault holding the token has its **enabled** switch off. | | `409` | `pan_streaming_unsupported` | The request detokenizes a `pan` token *and* asks for a streamed response. A stream cannot be re-tokenized — see [Responses are re-tokenized](#responses-are-re-tokenized). | | `502` | `response_not_retokenizable` | The upstream answered a request that detokenized a `pan` token with a body KnoxCall could not scan (compressed, or binary). The reply was withheld rather than forwarded unscanned. **Do not blindly retry** — the upstream call already happened. | | `500` | `internal_error` | A KnoxCall fault — a key, a KMS or the database. The only one worth retrying. | Every `4xx` above is final: the identical request is refused identically. The Routes surface answers the same conditions with its own `relay_*` codes — see [why a Route field action refused your request](/essentials/vaults/overview#why-a-route-field-action-refused-your-request). ### Raw card numbers are refused on egress KnoxCall **refuses to forward a request whose body contains a payment card number in the clear**, unless you have explicitly named the destination as one that may receive raw card data. This applies in **both** body modes, including `X-Knox-Proxy-Mode: transparent`, whose bytes are scanned as text. ```json theme={"dark"} HTTP/1.1 403 Forbidden { "error": { "type": "raw_pan_egress_refused", "message": "This request body contains what looks like a payment card number in the clear, and 'api.example-psp.com' is not allowed to receive raw card data. …" } } ``` **The two ways forward.** 1. **Tokenize the card and send the token** — the intended path. Put the number in a vault whose `token_format` is `pan` and send `{{ token: kcv_… }}`. KnoxCall detokenizes it only toward that vault's own `allowed_destinations`, so the number reaches your processor and nothing else. A value detokenized this way is never refused by this check. 2. **Allow the destination** — if a host genuinely must receive the number itself, an owner or admin replaces the workspace's raw-card egress allowlist: ```http theme={"dark"} PUT /admin/pan-egress/allowed-hosts Content-Type: application/json { "allowed_hosts": ["api.example-psp.com", "*.example-psp.com"] } ``` Entries are bare hostnames or `*.suffix` wildcards — no scheme, port, path or IP literal. **Adding** a host requires a fresh second factor; removing one does not, because shutting the door is the lever you want reachable during an incident. **On a configured Route there is a narrower option** — the route's Security tab grants raw-card egress for that route in that environment alone, without opening the destination to `/v1/proxy`, workflows or webhooks. See [Routes](/essentials/routes/creating-routes#raw-card-numbers-are-refused-on-egress). Every change is audited as `egress.pan_allowed_hosts_updated` with the previous list, so it can be reverted from the trail alone. **The list is empty by default**, which is why the refusal is what you see first. (There is no dashboard screen for this yet — the API is the surface.) The matching `GET` returns the list plus `refused_this_month`, so you can see how often the refusal has fired without trawling the audit log. **What counts as a card number.** 13–19 digits, optionally grouped with spaces or hyphens, that pass the Luhn checksum **and** begin with a recognised issuer prefix (Visa, Mastercard, Amex, Discover, Diners, JCB, UnionPay). An ordinary 16-digit order or reference number is not refused: both tests have to pass, and roughly nine in ten arbitrary 16-digit numbers fail the checksum alone. **Where else it applies.** The same refusal runs on configured Routes, the workflow **HTTP Request** step, the AI Gateway, and outbound webhook payloads — so there is no second door. Each writes an `egress.raw_pan_refused` audit entry naming the destination host and a masked sample (last four digits only); the number itself is never logged, never stored and never echoed back to you. ## Limits and gates | Gate | Behaviour | | - | - | | **Plan** | Requires the **Starter** plan or above. `402 plan_limit` if disabled. | | **Permission** | `proxy:invoke` on resource type `ephemeral_proxy`. Per-vault `vault:detokenize` is also evaluated for each referenced token. | | **Tokens per request** | Maximum **20** `{{ token: ... }}` references per request. `400 validation_error` past the cap. | | **Upstream URL** | HTTPS only, DNS hostname only (no IP literals), private/loopback ranges blocked, dangerous metadata endpoints blocked. | | **Timeout** | Default 25s, max 30s. | ## Audit and observability Every invocation writes: * An `api_requests` row with `proxy_mode = 'ephemeral'`, status code, latency, upstream host, token count, and source IP. **The body, headers, and token values are not stored.** * An `audit_log` entry tagged `ephemeral_proxy.invoke`, carrying `retokenized_occurrences` — how many echoed card numbers were replaced with their token on the way back (`0` on every request that opened none). * One `vault.retokenize` entry per vault whose card number the upstream echoed, with the occurrence count. The vault's id is recorded; the number and the token never are. * A Fleet Graph signal carrying `(method, upstream_host, status_code, latency_ms)` — never the body, never the token strings, never the customer's path. You can query the audit chain via `GET /v1/audit-logs?action=ephemeral_proxy.invoke` ([Audit Logs](/api-reference/audit-logs/list)). ## Comparison: Routes vs Ephemeral Proxy | | Routes | Ephemeral Proxy | | - | - | - | | Lifecycle | Persistent row, configured once, called many times. | One-shot — no row, no config. | | Upstream URL | Set per route, can be overridden per environment. | Set per request via `X-Knox-Proxy-URL`. | | Discovery in dashboard | Listed under **Routes**, full per-route metrics. | Not listed — discoverable via audit logs and Fleet Graph. | | Best for | Repeat traffic to a known partner API. | Tokenization passthrough, dynamic destinations, lightweight use cases. | | Headers, signing, IP allowlist, mTLS | All configurable. | Headers passthrough only; SSRF + plan gating only. | | Token resolution | Configured per-route via injection rules. | Inline `{{ token: ... }}` syntax in request body / query. | ## Next steps * See the [Ephemeral Proxy API reference](/api-reference/ephemeral-proxy/invoke) for the complete request/response schema. * See [Vaults](/essentials/secrets/secrets-overview) to create the tokens you'll reference. * See [Routes](/essentials/routes/what-are-routes) when you need a persistent, observable proxy instead. # Rate Limiting overview Source: https://docs.knoxcall.com/essentials/rate-limiting/overview Burst, sustained, and daily quota limits per route, per client, and per tenant. Enforced at the proxy edge so a runaway script can't blow your upstream quota. # Rate Limiting overview KnoxCall enforces rate limits at the **proxy edge** — before the request ever reaches your upstream API. A misbehaving client hits a `429` from KnoxCall, your upstream sees nothing, your daily Stripe / OpenAI / Anthropic quota stays intact. Three independent limit tiers run on every request. Any one of them tripping returns 429: * **Per-route** — a single route's burst + sustained + daily ceiling. * **Per-client** — fairness within a route, so one customer can't starve the others. * **Per-tenant** — a global ceiling for your tenant, useful for trial limits or as a hard kill-switch. A request that's allowed by all three tiers proceeds; the 429 response — when one is returned — comes back with the precise window that failed and a `Retry-After` header. ## Why use it | Problem | Without rate limiting | With KnoxCall | | - | - | - | | Runaway script blows a daily Stripe quota at 2am | Your team finds out from Stripe at 9am | Hits 429 at burst threshold; never reaches Stripe | | One noisy customer monopolises the API | Legitimate traffic queues behind their bursts | Per-client limits enforce fairness without per-customer code | | Free-tier abuse | Manual ban + chase | Tenant-level cap; abuse self-throttles | | Upstream provider's own rate limits trigger | Cascading 429s, retries multiply load | Upstream quota headers ([Anthropic](https://docs.anthropic.com/), [OpenAI](https://platform.openai.com/)) parsed and respected automatically | ## The three tiers Each tier shares the same shape: a configurable `requests` count over a sliding window, plus an optional `burst` allowance for short spikes. ### Per-route Configured per route. Typical settings: | Window | Use for | | - | - | | **Burst (RPS)** | Spike protection. Sliding 1-second window. | | **Sustained (RPM / RPS)** | Steady-state ceiling. 1-minute or 5-minute window. | | **Daily quota** | Cost control. Resets at UTC midnight or your configured tz. | A route's burst is usually 2-3× sustained. KnoxCall uses a sliding window, not a fixed bucket, so traffic doesn't pile up at window boundaries. ### Per-client When a route is fronted by [Clients](/essentials/clients/what-are-clients) (one client = one of your customers / SDKs / integrations), you can set per-client RPS and per-day caps. KnoxCall identifies the client from its API key on the inbound request. This is the lever for fairness: 100 RPS total on a route, with each client capped at 10 RPS, means no single noisy client can starve the others. ### Per-tenant A tenant-wide ceiling across all routes, set by your plan. Unlike the route and client limits it is a **token bucket**, not a sliding window: it refills continuously at your plan's per-minute rate (Free 100, Starter 1,000, Pro 5,000, Enterprise 10,000), and holds **ten seconds** of that rate (17 / 167 / 834 / 1,667) — the most you can send at once. Sent evenly, your whole per-minute rate is admitted; a burst larger than the bucket is refused with `X-RateLimit-Layer: tenant`, and `X-RateLimit-Burst` on that 429 states the bucket. See [Rate limiting](/advanced/rate-limiting) for the headers. It is useful for: * **Trial limits** — cap free-tier tenants at N requests/day across all routes. * **Hard kill-switch** — set a low ceiling during incident response to limit blast radius. * **Predictable cost** — your monthly egress / upstream-API cost is bounded by the tenant ceiling. ## Sliding window semantics KnoxCall uses **Redis sorted-set sliding windows**, not fixed buckets. Every request stamps the current timestamp; counting "how many requests in the last 60 seconds" walks the sorted set and counts members in the window. Practical effect: a client sending 60 requests in the first second of a minute, then waiting, sees the limit relax linearly over the next 60 seconds. With a fixed-bucket implementation they'd be blocked for the rest of that minute then free to send another 60 immediately at the boundary — the classic burst-at-window-boundary attack. If Redis is **disabled or not configured** (e.g. local dev), KnoxCall falls back to an **in-memory** counter per process — less accurate for a multi-replica deployment, but limits are still enforced. If a *configured* Redis becomes unreachable, proxy rate limits **fail open** instead: the request is allowed with full remaining quota so a control-plane Redis outage doesn't block tenant traffic. (Auth-sensitive limits fail *closed* — denying the request — rather than falling back.) ## 429 response shape When any tier trips: ```http theme={"dark"} HTTP/1.1 429 Too Many Requests Retry-After: 23 X-RateLimit-Limit: 100 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 2026-05-04T12:00:23Z X-RateLimit-Layer: route Content-Type: application/json { "error": "Too Many Requests", "message": "Route rate limit exceeded. Try again in 23 seconds.", "limit": 100, "remaining": 0, "reset": "2026-05-04T12:00:23Z", "layer": "route" } ``` Your client SDK reads `Retry-After` and waits. The `X-RateLimit-Layer` header tells you *which* limit tripped (`"route"` or `"tenant"`) — useful for debugging "is this me being noisy or a tenant-wide ceiling?". ## Upstream quota header passthrough KnoxCall parses upstream rate-limit signals and surfaces them in your **API Logs** automatically: | Provider | Headers parsed | | - | - | | Anthropic | `anthropic-ratelimit-{requests,tokens,input-tokens,output-tokens}-{limit,remaining,reset}` | | OpenAI | `x-ratelimit-{limit,remaining,reset}-{requests,tokens}` | | Stripe | `Stripe-Should-Retry`, `Retry-After` | | GitHub | `X-RateLimit-{Limit,Remaining,Reset}` | These power [smart\_ai alerts](/essentials/inbound-webhooks/overview) — KnoxCall fires you a Slack ping when an upstream quota crosses 80% so you can react before it hits 100%. ## Quick start (UI) 1. Go to **Routes → \[your route] → Rate Limiting** tab. 2. Set burst (e.g. `200 RPS`), sustained (`100 RPS`), and daily quota (`1M / day`). 3. If the route uses Clients, set per-client caps in the Clients tab. 4. For tenant-wide ceilings, **Settings → Billing → Quotas**. ## Quick start (API) ```bash theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) # Set rate limits on an existing route (environment config PATCH) curl -X PATCH https://api.knoxcall.com/v1/routes/stripe-charges \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "rate_limit_enabled": true, "rate_limit_requests": 100, "rate_limit_window_sec": 1, "rate_limit_burst": 200 }' ``` ## Plan limits | Tier | Per-route limits | Per-client limits | Per-tenant ceiling | | - | - | - | - | | Free | basic burst | not available | 100 calls/day | | Starter | burst + sustained | not available | 10K calls/month | | Pro | full (burst + sustained + daily) | available | 1M calls/month | | Enterprise | full | available | unlimited | Rate limiting is enabled on Pro and above. Free / Starter get the per-tenant ceiling automatically as part of the plan; per-route customisation unlocks at Pro. ## Next steps * [Routes overview →](/essentials/routes/what-are-routes) * [Clients overview →](/essentials/clients/what-are-clients) — for per-client fairness * [Audit Logs →](/essentials/audit-logs/overview) — every 429 lands here # Route Configuration Guide Source: https://docs.knoxcall.com/essentials/routes/creating-routes Configure routes with headers, methods, environments, and rate limiting. Intermediate guide. # Route Configuration Guide Learn how to configure routes with custom headers, method filtering, and environment-specific settings. ## HTTP Methods Control which HTTP methods are allowed: ### Allow All Methods By default, all methods (GET, POST, PUT, PATCH, DELETE) are allowed. ### Restrict Methods 1. Edit your route 2. Under **Allowed Methods**, uncheck methods you want to block 3. Example: Only allow GET and POST **Use case:** Prevent clients from deleting data (block DELETE) ## Header Injection Add custom headers to requests sent to your backend: ### Example: Add API Key ```json theme={"dark"} { "X-API-Key": "your-backend-api-key", "X-Source": "knoxcall" } ``` ### Example: Add Authentication ```json theme={"dark"} { "Authorization": "Bearer {{secret:stripe_api_key}}" } ``` *Use `{{secret:secret_name}}` to inject secrets* ## Body Injection Add data to the request body: ### Example: Add Tenant ID ```json theme={"dark"} { "tenant_id": "12345", "source": "knoxcall" } ``` **Note:** Only works with POST, PUT, PATCH requests ## Raw card numbers are refused on egress A request whose body carries a payment card number **in the clear** is refused with `403 raw_pan_egress_refused` and is **not forwarded** — unless the route's destination host has been allowed for raw card data, either **on this route** or **for the whole workspace**. The check runs after header injection, body injection and any Relay field actions, on the exact bytes that would go on the wire — so a card number that arrives in the request, one that a `{{secret:…}}` placeholder injects, and one a field action produces are all seen. It covers every transport a route can use: direct, mTLS, VPN and static-egress. **One value is exempt, and only one:** a card that a Relay `detokenize` action just resolved from a `pan` vault. That vault's own `allowed_destinations` was already checked against this route's host *before* anything was decrypted, so the card path a route is built for keeps working. **If you see this refusal**, you have three ways forward, narrowest first. 1. **Tokenize the number** into a `pan` vault and let a Relay `detokenize` action put it back on the way out. This is the intended path and the only one where the clear number never exists outside the hop that needs it. 2. **Allow it on this route alone.** Open the route, go to **Security**, and turn off **Enable security defaults** to reveal the individual protections. Then turn off *Refuse raw card numbers* — the switches in that card all read the same way round, on meaning protected, so allowing a card number is switching a protection **off**. The grant covers **this route in this environment** and nothing else — not your other routes, not workflow steps, not webhooks, not `/v1/proxy`. Switching the protection off allows the route's current destination, labelled as such; **Add another destination** beneath it takes further hostnames or `*.suffix` wildcards for the exceptions (a per-method target, a processor with a host family). An owner or admin, a fresh second factor to add a host (never to remove one), audited as `egress.route_pan_allowed_hosts_updated`. 3. **Allow it for the whole workspace** with `PUT /admin/pan-egress/allowed-hosts` (see [the ephemeral proxy's section on this refusal](/essentials/proxy/ephemeral-proxy#raw-card-numbers-are-refused-on-egress) for the request shape). This opens the destination on *every* egress path at once, so prefer option 2 when only one route needs it. Audited as `egress.pan_allowed_hosts_updated`. Both allowlists are empty by default, and they are a **union** — a route grant can only add a destination, never take one away that the workspace already allows. The route grant records the **hostname**, not a simple on/off. If you later point the route at a different target, the grant no longer covers it and card numbers are refused again — deliberately, so that consent you gave for one destination cannot follow the route somewhere you never approved. The security defaults card says so when it happens — and says it whether or not the individual protections are showing — and the grant is not settable through the `/v1` API: an API key that can proxy must not be able to authorise itself a destination for raw card numbers. **Self-hosted data planes: the route grant does not apply in `production` yet.** A self-hosted node serves production traffic from the bundle the control plane syncs to it, and that bundle does not carry the per-route grant. Non-production environments on the same node read the database directly and DO honour it, so the grant will appear to work in your test environment and then stop at production — which is the opposite of what you would expect, and the reason this warning exists. It always fails **closed**: a route whose grant is not seen refuses the card number, it never forwards one you did not allow. Until the bundle carries the column, use the workspace-wide allowlist for production on self-hosted. Cloud deployments are unaffected. Each refusal writes an `egress.raw_pan_refused` audit entry with the destination host and a masked sample; the number is never logged or stored. Detection is 13–19 digits (spaces and hyphens allowed) that pass the Luhn checksum **and** carry a recognised issuer prefix, so an ordinary 16-digit order number is not affected. ## Moving a route that decrypts or detokenizes If a route carries a standing **`decrypt`** or **`detokenize`** field action (`POST /v1/routes/{routeId}/actions`), changing **where it sends** is treated like revealing the values it opens. A `decrypt` action opens `kc:` ciphertext and a `detokenize` action turns a vault token back into the card number or SSN behind it, on every call through the route. On the **request** side the opened value is sent to the route's upstream; on the **response** side the upstream's own values are opened and returned to the caller. Either way, whoever chooses the upstream chooses who reads opened values, so giving such a route a **new destination** needs the same authority as attaching the action: * **In the dashboard:** an **Owner or Admin**, a fresh second factor (a security key, passkey or authenticator code — never an emailed code), and an optional reason, recorded in the audit log as a `plaintext.reveal` entry (`surface: route.destination_change`) **before** the change is saved. An Editor sees why on the route page and is refused, whether or not they have **Reveal secrets**. You are asked when you **Save**, never while you edit, and once per Save however much it changes: the page sends the Target URL, the configuration, the allowed methods and the **Serve traffic** switch together. Cancelling the verification saves nothing. Pushing a Target URL to every environment asks once for all of them, and a double-click on any of the page's commands sends the change once and asks once. * **On the API (`/v1`):** a token issued to a **person** (`knoxcall login`, a device or authorization-code grant) is refused; a **machine** credential must hold the action's own permission — `transit:decrypt` on its key, or `vaults:detokenize` on its vault. The change is recorded as `route.destination_change` before it is written. * **The onboarding assistant** cannot do it; it points you at the route page. "A new destination" means a URL the route does not already store: a new base URL, a new per-method target URL (active or not), or a new environment carrying a URL the route has never had — including one a collection's defaults would provision. It also means **starting to send to a URL the route only stores**: turning on a disabled environment, turning on method-specific configs (so their target URLs go live), allowing a method that reaches a base URL every allowed method used to bypass, copying a URL that sits idle into a base URL, or duplicating an environment whose URL sits idle into one that serves traffic. A URL counts as one the route **already sends to** only when a `GET`, `POST`, `PUT`, `PATCH` or `DELETE` request reaches it today; a URL that only another method reaches (a route restricted to `HEAD`, or to a method name no client sends) is treated as idle. Because two saves made at the same moment could each look harmless, a change is judged as if the environment's switches (enabled, the method restriction, method-specific configs) were all open: on an environment that holds an idle URL, a save that would send to it once those switches open — or placing an idle URL into a paused environment — asks for the verification too. Re-saving a URL the route already uses, removing one, turning an environment off, duplicating an environment the route already sends to, and renaming an environment need nothing extra. Routes with no such action — and routes whose actions only `encrypt` or `tokenize` — are unaffected. ## Path Handling The request path is **appended verbatim** to the route's target base URL. KnoxCall does not rewrite, strip, or remove any path prefix. For example, if your target base URL is `https://api.example.com` and a client requests `/api/v1/users/123`, the request is forwarded to: ```text theme={"dark"} https://api.example.com/api/v1/users/123 ``` If the target base URL already includes a path (for example `https://api.example.com/backend`), the client path is appended to it (`https://api.example.com/backend/api/v1/users/123`). Query parameters from the incoming request are preserved. A target URL must point at a third-party upstream. Private, loopback and cloud-metadata addresses are refused, and so is any KnoxCall host (`api.knoxcall.com`, `hooks.knoxcall.com`, another workspace's `*.knoxcall.com` address, or your own) — KnoxCall never sends your route's traffic back to itself. To call one of your routes from a workflow, use the HTTP Request node's **Route** mode instead of typing the route's URL. ## Environment Configuration Set different target URLs per environment: 1. Edit route 2. Scroll to **Environment Configuration** 3. For each environment: * **Development**: `http://localhost:3000` * **Staging**: `https://staging-api.example.com` * **Production**: `https://api.example.com` Clients specify environment via header: ```http theme={"dark"} x-knoxcall-environment: production ``` ## Rate Limiting Limit requests per client: 1. Enable **Rate Limiting** 2. Set limits: * **Requests**: 100 * **Window**: 60 (seconds) * **Burst**: 120 This allows 100 requests per minute, with bursts up to 120. ## Timeout Settings Default timeout is 30 seconds. Contact support to adjust. ## Testing Configuration After making changes: 1. Click **Test Route** 2. Select environment 3. Enter sample request 4. View response ## Need More Help? * See the Quick Start Guide for basics * Learn about secrets for secure credential injection * Check troubleshooting guides for common issues *** * **Level**: intermediate * **Time**: 10 minutes `routes`, `configuration`, `intermediate` # Route-aware interception Source: https://docs.knoxcall.com/essentials/routes/route-aware-interception Send an untouched third-party SDK's traffic through the Route that covers it — no base-URL swap, no per-SDK wiring. Preview. # Route-aware interception **Preview.** Turn a Route's **Intercept** toggle on, install the KnoxCall SDK's interceptor once, and every call your code — or any third-party SDK inside it — makes to that Route's upstream host goes through the Route. The Route injects the stored credential; the provider key never enters your process. Turn the toggle off and the traffic goes back to where it went before, on the next refresh, with no code change. ```ts theme={"dark"} const knox = new KnoxCall(); // credentials from KNOXCALL_* env const stop = knox.wrap.intercept(); // route discovery is on by default await stop.ready; // first manifest loaded const hubspot = new Client({ accessToken: "placeholder" }); // an untouched SDK await hubspot.crm.contacts.basicApi.getPage(); // via the Route that covers api.hubapi.com ``` The same feature exists in every SDK — [Node](/sdks/node#route-aware-interception-preview), [Python](/sdks/python#route-aware-interception-preview), [Go](/sdks/go#route-aware-interception-preview), [PHP](/sdks/php#route-aware-interception-preview), [Ruby](/sdks/ruby#route-aware-interception-preview) — and in the [Go agent's intercept mode](/infrastructure/go-agent) for code you cannot change at all. All of them read one server answer, so a Route you toggle on in the dashboard takes effect for all of them. ## How it works 1. **You opt a Route in.** `intercept_enabled` is a per-environment setting on the Route (the **Intercept** toggle on each environment card of the Route page, or `intercept_enabled` on the [create](/api-reference/routes/create), [update](/api-reference/routes/update) and [environment](/api-reference/routes/environments) endpoints). It is off by default. Only one intercept-enabled Route may cover a given upstream host and base path in an environment — turning on a second is refused with `409 intercept_route_conflict`, because two Routes claiming the same traffic would be resolved by a tie-break nobody chose. 2. **The SDK polls the manifest.** `GET /v1/wrap/intercept-manifest` lists, for one environment, every intercept-enabled Route's upstream `host`, `base_path` and `slug`. The SDK refreshes it every 60 seconds (jittered), and immediately when KnoxCall refuses a route-mode request — a `401`, or the `404 route_not_found` an authenticated credential gets for a Route that no longer exists — so a Route created, enabled or disabled later is picked up without a restart. A `404` of type `environment_not_configured` or `environment_disabled` is surfaced as-is: a refresh cannot fix an environment. Your provider's own 404s are never mistaken for either — they carry `X-Knox-Upstream-Status`. 3. **Each outbound request is decided in a fixed order.** The first matching rule wins: | # | Condition | Outcome | | - | - | - | | 0 | `KNOXCALL_INTERCEPT=off` in the environment | direct — the kill switch, per process, no deploy | | 1 | Not `http(s)`, or the URL does not parse | direct | | 2 | The SDK's own KnoxCall hosts, or any host under `knoxcall.com` | direct (anti-recursion) | | 3 | A route-around rule matches (the built-in raw-card defaults, plus yours) | direct | | 4 | You asked for `requireContext` and this call is outside `routed()` | direct | | 5 | A manifest entry covers the host **and** the path (longest `base_path` wins) | **route mode** — through that Route | | 6 | The host is in the `hosts` list you gave `intercept()` | **ephemeral mode** — through the ephemeral proxy | | 7 | otherwise | direct — untouched | Rule 5 before rule 6 is the whole feature: a host you listed is served by the ephemeral proxy until a Route covers it, upgrades to the Route the moment one does, and downgrades back when the Route is disabled. ## Route mode In route mode the SDK sends the request to your tenant's data plane with `x-knoxcall-route: `, the path rebased under the Route's `base_path` (a Route targeting `https://api.hubapi.com/crm/v3` receives `/objects/contacts` for a request to `/crm/v3/objects/contacts`), and the query string intact. The request's own `Authorization` header is dropped before it leaves — the Route injects the stored secret, so a placeholder token in the third-party SDK is exactly that. Everything a Route does applies: injected headers, per-environment overrides, rate limits, client requirements, logs and analytics. The API Log marks each call the SDK rerouted as **SDK intercept** (a direct `call()` shows as **Direct**), so you can see exactly which traffic the interceptor is carrying. A request under a covered host but **not** under any covered base path is not rerouted; it falls to rule 6 (ephemeral if the host is listed, direct otherwise) and fires `onUnmatchedPath` once per host and path prefix so you can see it. ## Ephemeral mode A host you list in `hosts` that no Route covers goes through the [ephemeral proxy](/essentials/ephemeral-proxy/what-is-the-ephemeral-proxy) in transit mode: the request's own credential is carried on a KnoxCall header and the SDK's KnoxCall token authenticates the hop. Per-host options put a host in escrow mode instead (`credential: { secret: "" }`), or let transit go direct when KnoxCall is unreachable (`unavailable: "direct"`). ## When KnoxCall is unreachable Route mode and escrow fail closed: there is no credential in your process to go direct with, so the SDK surfaces the connection error. Transit is the one mode that can opt out, per host, with `unavailable: "direct"`; it fires `onFallback` when it does. ## What it is, and is not * **It is a convenience, not a security boundary.** Each SDK patches the process-wide HTTP seam its language has (global `fetch` and `node:http` in Node; `httpx` and `urllib3` in Python; `http.DefaultTransport` in Go; `Net::HTTP` in Ruby). PHP has no process-wide seam, so the SDK offers a route-aware PSR-18 client and a Guzzle middleware instead. Traffic that does not pass through that seam is not reached — each SDK page states exactly what is and is not covered. * **The custody path is route mode.** Ephemeral transit carries your own credential through KnoxCall; escrow and route mode keep it out of your process entirely. * **The manifest is a read.** It needs `route:read` on the SDK's credential, the same permission as listing routes; it is not permission to escrow, mint or spend anything. * **One kill switch, no deploy.** `KNOXCALL_INTERCEPT=off` makes every interceptor and route-aware transport pass everything through, per process. ## Which stacks are reached | Language | Installed by | Reached | Not reached | | - | - | - | - | | Node | `knox.wrap.intercept()` | global `fetch` (undici), `node:http` / `https` — axios, node-fetch, got, cross-fetch, OpenAPI-generated clients, `@hubspot/api-client` | a `fetch` or `request` reference captured before install; a custom undici `Dispatcher` | | Python | `knox.wrap.intercept(stacks=["httpx", "urllib3"])`; on the async client also `stacks=["aiohttp"]` (opt-in) | `httpx` clients on the default transport; `requests`, `botocore` and OpenAPI-generated clients (urllib3); with the opt-in, any `aiohttp.ClientSession` — aiobotocore, slack\_sdk's async client, azure-core's aiohttp transport | an injected custom transport; raw `http.client`; `pycurl`; a WebSocket handshake (`ws_connect` goes direct) | | Go | `client.Wrap.Intercept(ctx)` | `http.DefaultClient` and any `*http.Client` with a nil `Transport` | a client built with its own `Transport` — hand it `client.Wrap.RoundTripper(knoxcall.WithRoutes())` | | PHP | `$knox->wrap->httpClient(['routes' => 'auto'])`, `$knox->wrap->guzzleMiddleware()` | any SDK that accepts a PSR-18 client or a Guzzle handler stack | an SDK that builds its own curl handle — point it at a gateway URL | | Ruby | `knox.wrap.intercept!` (opt-in, experimental) | Faraday's default adapter, `rest-client`, `httparty`, raw `Net::HTTP` | Typhoeus, Curb, `http.rb` | Every SDK also offers the same decisions on an explicit transport you inject yourself (`wrap.fetch({ routes: "auto" })`, `wrap.transport(routes="auto")`, `Wrap.RoundTripper(WithRoutes())`, `wrap->httpClient(['routes' => 'auto'])`, `wrap.faraday_connection(routes: :auto)`), for code that would rather not patch process globals. ## Uncovered egress The interceptor already sees every outbound request the process makes, so it can also tell you which ones it **did not** intercept: calls that carried a credential to a host no Route covers, or to a host whose Route has interception switched off. Those are reported to KnoxCall and shown on **Proxy → Uncovered egress** in the dashboard, with one action per row — create a Route, or turn interception on for the one that already matches. **What is sent.** Per host, the first path segment (`/` or `/`), the HTTP method, the *name* of the header the credential travelled in (`authorization`, `x-api-key`, …) and how many calls, with first- and last-seen timestamps. Nothing else: no credential values, no full paths, no query strings, no request or response bodies, no client addresses. The server refuses an entry that carries anything beyond that shape, and the header name must be on a fixed allowlist of credential headers — so a value cannot be reported even by mistake. Some APIs put a key in the path itself (a Telegram bot token is `/bot:/…`), so a first path segment that looks like a credential — a known key prefix, a token, anything unusually long — is stored as `/` instead: the host is still listed, the value is not kept. **On by default.** Every `intercept()` install reports unless told not to. Switch it off per install with `observe_uncovered: false`, or per process with `KNOXCALL_OBSERVE_UNCOVERED=off`; either stops the reporter entirely. `KNOXCALL_INTERCEPT=off` stops it too, since there is no interceptor to observe with. **Retention.** Observations are kept per host and day for **30 days**, then deleted. A workspace may add at most 5,000 distinct (host, path segment, method, header name) combinations per day; beyond that the SDK's report is accepted and the excess is dropped. The endpoint is `POST /v1/wrap/egress-observations` and needs `route:read`, the same permission as the manifest. ## Hooks `onReroute` (before a KnoxCall send: host, URL, mode, slug), `onRefresh` (after a manifest change: added and removed hosts), `onManifestError`, `onUnmatchedPath`, `onRefused` (a route-mode refusal that triggered a refresh, and what the re-decision was), `onFallback` (a transit request went direct). Names follow each language's casing. ## See also * [What are Routes?](/essentials/routes/what-are-routes) and [Creating routes](/essentials/routes/creating-routes) * [Route environments](/api-reference/routes/environments) — `intercept_enabled` per environment * [Go agent](/infrastructure/go-agent) — the same manifest, for processes whose code you cannot change # Common Route Patterns Source: https://docs.knoxcall.com/essentials/routes/route-examples Real-world route examples and patterns. Learn from production-ready configurations for webhooks, APIs, payments, and more. # Common Route Patterns Production-ready examples of common route configurations. Copy, adapt, and deploy. Client requests below lead with the KnoxCall SDK — `const client = new KnoxCall()` picks up credentials from [`knoxcall login`](/api-reference/authentication) or `KNOXCALL_CLIENT_ID` / `KNOXCALL_CLIENT_SECRET`. In raw curl examples, `$TOKEN` is a minted OAuth access token (see [Authentication](/api-reference/authentication)); legacy API keys still work by sending `-H "x-knoxcall-key: tk_live_..."` instead of the `Authorization` header. *** ## Payment APIs ### Stripe Payment Processing **Use case:** Process payments securely without exposing Stripe API key **Configuration:** ```json theme={"dark"} Route: stripe-payments Target: https://api.stripe.com Methods: POST ``` **Headers:** ```json theme={"dark"} { "Authorization": "Bearer {{secret:stripe_prod_key}}", "Content-Type": "application/json", "Stripe-Version": "2023-10-16" } ``` **Environment overrides:** ```text theme={"dark"} Production: Secret: stripe_prod_key (sk_live_...) Staging: Secret: stripe_staging_key (sk_test_...) ``` **Client request (SDK):** ```typescript theme={"dark"} const resp = await client.call("stripe-payments", { method: "POST", path: "/v1/charges", body: { amount: 1000, currency: "usd", source: "tok_visa" }, }); ``` **Client request (curl):** ```bash theme={"dark"} curl -X POST https://YOUR-SUBDOMAIN.knoxcall.com/v1/charges \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: stripe-payments" \ -d "amount=1000" \ -d "currency=usd" \ -d "source=tok_visa" ``` **Why this works:** * Client never sees Stripe key * Environment-specific keys (test/live) * Full Stripe API access through proxy *** ### PayPal Payments **Use case:** PayPal checkout integration **Configuration:** ```json theme={"dark"} Route: paypal-checkout Target: https://api.paypal.com Methods: POST, GET ``` **Headers:** ```json theme={"dark"} { "Authorization": "Bearer {{secret:paypal_access_token}}", "Content-Type": "application/json" } ``` **OAuth2 Secret:** ```text theme={"dark"} Type: OAuth2 Name: paypal_access_token Token URL: https://api.paypal.com/v1/oauth2/token Client ID: {{from PayPal dashboard}} Client Secret: {{from PayPal dashboard}} ``` **Auto-refresh:** ✅ KnoxCall refreshes token automatically *** ## Email Services ### SendGrid Transactional Email **Use case:** Send emails without exposing SendGrid API key **Configuration:** ```json theme={"dark"} Route: sendgrid-email Target: https://api.sendgrid.com Methods: POST ``` **Headers:** ```json theme={"dark"} { "Authorization": "Bearer {{secret:sendgrid_api_key}}", "Content-Type": "application/json" } ``` **Body template:** ```json theme={"dark"} { "personalizations": [{ "to": [{"email": "{{var:to_email}}"}], "subject": "{{var:subject}}" }], "from": { "email": "noreply@yourdomain.com", "name": "Your App" }, "content": [{ "type": "text/html", "value": "{{var:email_body}}" }] } ``` **Client request (SDK):** ```typescript theme={"dark"} await client.call("sendgrid-email", { method: "POST", path: "/v3/mail/send?to_email=customer@example.com&subject=Welcome!&email_body=%3Ch1%3EWelcome%20to%20our%20service!%3C/h1%3E", }); ``` **Client request (curl):** ```bash theme={"dark"} curl -X POST "https://YOUR-SUBDOMAIN.knoxcall.com/v3/mail/send?to_email=customer@example.com&subject=Welcome!&email_body=%3Ch1%3EWelcome%20to%20our%20service!%3C/h1%3E" \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: sendgrid-email" ``` Dynamic placeholders in body and header templates use the `{{var:NAME}}` form, where `NAME` is read from the request's **query-string parameters**. Pass these values as query params (as shown above), not in the request body. *** ### Mailgun Email **Use case:** Alternative email service **Configuration:** ```json theme={"dark"} Route: mailgun-email Target: https://api.mailgun.net Methods: POST ``` **Headers:** ```json theme={"dark"} { "Authorization": "Basic {{secret:mailgun_api_key}}", "Content-Type": "application/x-www-form-urlencoded" } ``` **Note:** Mailgun uses Basic auth (base64 encode `api:YOUR_KEY`) *** ## SMS/Communication ### Twilio SMS **Use case:** Send SMS messages **Configuration:** ```json theme={"dark"} Route: twilio-sms Target: https://api.twilio.com Methods: POST ``` **Headers:** ```json theme={"dark"} { "Authorization": "Basic {{secret:twilio_auth_token}}", "Content-Type": "application/x-www-form-urlencoded" } ``` **Secret format:** ```text theme={"dark"} base64(ACCOUNT_SID:AUTH_TOKEN) ``` **Client request (curl):** ```bash theme={"dark"} curl -X POST https://YOUR-SUBDOMAIN.knoxcall.com/2010-04-01/Accounts/ACCT_ID/Messages.json \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: twilio-sms" \ -d "To=+15555555555" \ -d "From=+14155552671" \ -d "Body=Hello from KnoxCall!" ``` *** ## Webhooks ### Receiving Stripe Webhooks **Use case:** Receive webhook events from Stripe **Configuration:** ```json theme={"dark"} Route: stripe-webhooks Target: https://your-backend.com Methods: POST ``` **Headers:** ```json theme={"dark"} { "Content-Type": "application/json" } ``` There is no `{{client_ip}}` template helper. The proxy strips reverse-proxy headers such as `X-Forwarded-For` from the request before forwarding it upstream, so the client IP is not passed through to your backend. **Clients:** ```text theme={"dark"} Type: Server IPs: Stripe webhook IPs - 3.18.12.63/32 - 3.130.192.231/32 - 13.235.14.237/32 - 13.235.122.149/32 ... (get full list from Stripe docs) ``` **Signature validation:** ```text theme={"dark"} Option 1: Validate in your backend Option 2: Use KnoxCall request signing ``` **Stripe configures webhook:** ```text theme={"dark"} Webhook URL: https://YOUR-SUBDOMAIN.knoxcall.com/webhooks/stripe ``` *** ### Receiving Shopify Webhooks **Use case:** E-commerce webhook notifications **Configuration:** ```json theme={"dark"} Route: shopify-webhooks Target: https://your-backend.com Methods: POST ``` **Headers:** ```json theme={"dark"} { "Content-Type": "application/json" } ``` There is no `{{header:...}}` template helper. Incoming request headers that aren't KnoxCall- or proxy-specific (such as `X-Shopify-Hmac-SHA256`, `X-Shopify-Topic`, and `X-Shopify-Shop-Domain`) are forwarded to your upstream automatically — you don't need to re-declare them here. **Clients:** ```text theme={"dark"} Allow: Shopify webhook IPs (Or use signature validation instead of IP whitelist) ``` *** ### Sending Webhooks to Partners **Use case:** Notify partner systems of events **Configuration:** ```json theme={"dark"} Route: partner-acme-webhook Target: https://webhooks.acme.com Methods: POST ``` **Headers:** ```json theme={"dark"} { "X-Webhook-Secret": "{{secret:partner_acme_webhook_secret}}", "Content-Type": "application/json", "X-Source": "YourCompany" } ``` **Body:** ```json theme={"dark"} { "event": "order.created", "timestamp": "{{var:timestamp}}", "data": { "order_id": "12345", "total": 99.99 }, "signature": "{{var:hmac_signature}}" } ``` KnoxCall does not generate timestamps or HMAC signatures for you. Compute these values on the client and pass them as query-string parameters (`?timestamp=...&hmac_signature=...`); the `{{var:NAME}}` placeholders read directly from the request query string. *** ## Database / Data APIs ### PostgreSQL REST API **Use case:** Query database through HTTP API **Configuration:** ```json theme={"dark"} Route: database-api Target: https://your-db-api.com Methods: GET, POST ``` **Headers:** ```json theme={"dark"} { "Authorization": "Bearer {{secret:database_api_token}}", "Content-Type": "application/json" } ``` **Example queries:** ```bash theme={"dark"} # Read curl https://YOUR-SUBDOMAIN.knoxcall.com/users/123 \ -H "x-knoxcall-route: database-api" # Write curl -X POST https://YOUR-SUBDOMAIN.knoxcall.com/users \ -H "x-knoxcall-route: database-api" \ -d '{"name": "John", "email": "john@example.com"}' ``` *** ### GraphQL API **Use case:** GraphQL backend proxy **Configuration:** ```json theme={"dark"} Route: graphql-api Target: https://api.example.com/graphql Methods: POST ``` **Headers:** ```json theme={"dark"} { "Authorization": "Bearer {{secret:graphql_api_token}}", "Content-Type": "application/json" } ``` **Client query:** ```bash theme={"dark"} curl -X POST https://YOUR-SUBDOMAIN.knoxcall.com/graphql \ -H "x-knoxcall-route: graphql-api" \ -d '{ "query": "{ users { id name email } }" }' ``` *** ## Cloud Storage ### AWS S3 Signed URLs **Use case:** Generate pre-signed URLs for S3 uploads **Configuration:** ```json theme={"dark"} Route: s3-signed-urls Target: https://your-backend.com/s3/sign Methods: POST ``` **Headers:** ```json theme={"dark"} { "X-AWS-Access-Key": "{{secret:aws_access_key}}", "X-AWS-Secret-Key": "{{secret:aws_secret_key}}" } ``` **Your backend:** * Generates signed URL using AWS credentials * Returns URL to client * Client uploads directly to S3 *** ### Google Cloud Storage **Use case:** Upload files to Google Cloud **Configuration:** ```json theme={"dark"} Route: gcs-upload Target: https://storage.googleapis.com Methods: POST, PUT ``` **Headers:** ```json theme={"dark"} { "Authorization": "Bearer {{secret:google_cloud_oauth_token}}", "Content-Type": "application/octet-stream" } ``` **OAuth2 Secret:** ```text theme={"dark"} Type: OAuth2 Auto-refresh: Enabled ``` *** ## Printing Services ### PrintNode Cloud Printing **Use case:** Send print jobs to remote printers **Configuration:** ```json theme={"dark"} Route: printnode-print Target: https://api.printnode.com Methods: POST ``` **Headers:** ```json theme={"dark"} { "Authorization": "Basic {{secret:printnode_api_key}}", "Content-Type": "application/json" } ``` **Body:** ```json theme={"dark"} { "printerId": 123, "title": "Receipt #12345", "contentType": "pdf_uri", "content": "https://yoursite.com/receipt.pdf", "source": "Your POS System" } ``` **Client request:** ```bash theme={"dark"} curl -X POST https://YOUR-SUBDOMAIN.knoxcall.com/printjobs \ -H "x-knoxcall-route: printnode-print" \ -d '{ "printerId": 123, "title": "Receipt", "content": "https://example.com/receipt.pdf" }' ``` *** ## Authentication / OAuth ### Auth0 Authentication **Use case:** User authentication proxy **Configuration:** ```json theme={"dark"} Route: auth0-login Target: https://YOUR-DOMAIN.auth0.com Methods: POST ``` **Headers:** ```json theme={"dark"} { "Content-Type": "application/json" } ``` **Body:** ```json theme={"dark"} { "client_id": "{{secret:auth0_client_id}}", "client_secret": "{{secret:auth0_client_secret}}", "audience": "https://your-api.com", "grant_type": "client_credentials" } ``` *** ### Google OAuth Token Exchange **Use case:** Exchange authorization code for tokens **Configuration:** ```json theme={"dark"} Route: google-oauth-token Target: https://oauth2.googleapis.com/token Methods: POST ``` **Headers:** ```json theme={"dark"} { "Content-Type": "application/x-www-form-urlencoded" } ``` **Body:** ```json theme={"dark"} { "client_id": "{{secret:google_client_id}}", "client_secret": "{{secret:google_client_secret}}", "code": "{{var:auth_code}}", "grant_type": "authorization_code", "redirect_uri": "https://yourapp.com/callback" } ``` *** ## Microservices Architecture ### Service-to-Service Communication **Use case:** Multiple internal services **Routes:** ```text theme={"dark"} user-service → https://users.internal.com order-service → https://orders.internal.com payment-service → https://payments.internal.com notification-service → https://notifications.internal.com ``` **Shared configuration:** ```json theme={"dark"} Headers (all services): { "X-Internal-Token": "{{secret:internal_service_token}}", "X-Service-Name": "api-gateway" } Clients: - Type: Server - IP: 10.0.0.0/16 (internal VPC) ``` **Benefits:** * Centralized authentication * Unified logging * Rate limiting across services * Easy service discovery *** ## Third-Party Integrations ### HubSpot CRM **Use case:** Sync customer data **Configuration:** ```json theme={"dark"} Route: hubspot-crm Target: https://api.hubapi.com Methods: GET, POST, PATCH ``` **Headers:** ```json theme={"dark"} { "Authorization": "Bearer {{secret:hubspot_access_token}}", "Content-Type": "application/json" } ``` **OAuth2 Secret:** Auto-refreshing HubSpot token *** ### Salesforce API **Use case:** CRM integration **Configuration:** ```json theme={"dark"} Route: salesforce-api Target: https://your-instance.salesforce.com Methods: GET, POST, PATCH, DELETE ``` **Headers:** ```json theme={"dark"} { "Authorization": "Bearer {{secret:salesforce_access_token}}", "Content-Type": "application/json" } ``` *** ### Slack Webhooks **Use case:** Send notifications to Slack **Configuration:** ```json theme={"dark"} Route: slack-notifications Target: https://hooks.slack.com Methods: POST ``` **Body:** ```json theme={"dark"} { "text": "{{var:message}}", "channel": "#notifications", "username": "Your App Bot", "icon_emoji": ":robot_face:" } ``` **No auth needed** (webhook URL is the secret) *** ## Testing & Development ### Mock API for Development **Use case:** Test against mock data **Configuration:** ```json theme={"dark"} Route: mock-api Target: https://jsonplaceholder.typicode.com Methods: GET, POST, PUT, DELETE ``` **Headers:** ```json theme={"dark"} { "Content-Type": "application/json" } ``` **Clients:** ```text theme={"dark"} Type: Server IP: 192.168.1.0/24 (office network) Environment: development only ``` *** ### Webhook Testing **Use case:** Test webhooks with webhook.site **Configuration:** ```json theme={"dark"} Route: webhook-test Target: https://webhook.site/YOUR-UNIQUE-ID Methods: POST ``` **Clients:** Development team only *** ## Multi-Environment Patterns ### Pattern: Development → Staging → Production **Route:** `api-proxy` **Base (Production):** ```text theme={"dark"} Target: https://api.production.com Secret: prod_api_key Clients: [prod-server-1, prod-server-2] ``` **Override (Staging):** ```text theme={"dark"} Target: https://api.staging.com Secret: staging_api_key Clients: [staging-server, qa-team] ``` **Override (Development):** ```text theme={"dark"} Target: http://localhost:3000 Secret: dev_api_key Clients: [ALL developers] ``` **Usage:** ```bash theme={"dark"} # Production curl ... -H "x-knoxcall-environment: production" # Staging curl ... -H "x-knoxcall-environment: staging" # Development curl ... -H "x-knoxcall-environment: development" ``` *** ## Security Patterns ### API Key Rotation Without Downtime **Setup two routes:** ```text theme={"dark"} Route 1: api-v1 (old key) Route 2: api-v2 (new key) ``` **Migration process:** 1. Create api-v2 with new key 2. Update clients gradually to use api-v2 3. Monitor api-v1 traffic 4. When api-v1 traffic = 0, delete it **Zero downtime!** *** ### Multi-Layer Security **Route:** `sensitive-api` **Security layers:** 1. **IP whitelist** - Only production servers 2. **API key** - KnoxCall authentication 3. **Request signing** - HMAC signatures 4. **Rate limiting** - 100 req/min 5. **Secret injection** - Backend API key **Configuration:** ```json theme={"dark"} Requires Clients: ✓ Require Signature: ✓ Rate Limit: 100/min Headers: { "Authorization": "Bearer {{secret:backend_api_key}}" } ``` *** ## Best Practices ### ✅ Do 1. **Use environment-specific secrets** ``` Production: stripe_prod_key Staging: stripe_test_key ``` 2. **Document route purpose** ``` Description: "Stripe payment processing - live transactions" ``` 3. **Start simple, add complexity** ``` 1. Basic route 2. Add secrets 3. Add rate limiting 4. Add signing ``` 4. **Test in staging first** ``` Staging → Success → Deploy to production ``` 5. **Use descriptive names** ``` ✅ stripe-prod-payments ❌ route1 ``` ### ❌ Don't 1. **Don't hardcode secrets** ``` ❌ "Authorization": "Bearer sk_live_abc123" ✅ "Authorization": "Bearer {{secret:stripe_key}}" ``` 2. **Don't use production keys in dev** ``` ❌ Development using prod Stripe key ✅ Development using test Stripe key ``` 3. **Don't skip IP authorization for prod** ``` ❌ Production route: requires_clients = false ✅ Production route: requires_clients = true ``` *** ## Quick Reference | Use Case | Target | Auth Method | Key Feature | | - | - | - | - | | Stripe | api.stripe.com | Bearer token | Secret injection | | SendGrid | api.sendgrid.com | Bearer token | Email templates | | Twilio | api.twilio.com | Basic auth | SMS/Voice | | Webhooks | your-backend.com | IP + signature | Event notifications | | PrintNode | api.printnode.com | Basic auth | Cloud printing | | OAuth | oauth provider | Client credentials | Token exchange | | Microservices | internal services | Internal token | Service mesh | *** ## Next Steps Route basics and concepts Step-by-step route creation Secret injection in routes Advanced route features *** **Pro Tip:** Copy these examples as starting points, then customize for your specific needs. Test in staging before deploying to production! # Testing Routes Source: https://docs.knoxcall.com/essentials/routes/testing-routes Use the built-in route tester to validate configuration before going live. Test headers, body, secrets, and response. # Testing Routes Use KnoxCall's built-in route tester to validate your configuration before sending real traffic. ## What is the Route Tester? The **Route Tester** is a built-in tool that lets you: * ✅ Test route configuration without writing code * ✅ See exactly what headers/body are sent to your backend * ✅ Verify secrets are injected correctly * ✅ Test different HTTP methods * ✅ Debug issues before going live * ✅ No need for Postman or curl ## Access the Route Tester ### Option 1: From Route List 1. Navigate to **Routes** 2. Find your route 3. Click the **Test** button (🧪 icon) ### Option 2: From Route Details 1. Navigate to **Routes** 2. Click your route to open details 3. Click **Test Route** tab 4. Or click **Test** button in header ## Using the Route Tester ### Step 1: Select HTTP Method Choose the method to test: * GET * POST * PUT * DELETE * PATCH * OPTIONS * HEAD ### Step 2: Enter Path Specify the URL path (appended to target base URL): **Example:** Target Base URL: `https://api.example.com` Path: `/users/123` **Result:** `https://api.example.com/users/123` **Tips:** * Include leading slash: `/api/users` ✅ * No leading slash: `api/users` ⚠️ (works but inconsistent) * Can include query params: `/users?status=active` ### Step 3: Add Query Parameters (Optional) Add query parameters that will be: 1. Sent to backend 2. Available in templates as `{{var:param_name}}` **Example:** | Key | Value | | - | - | | filter | active | | sort | name | | limit | 10 | **Result URL:** `/users?filter=active&sort=name&limit=10` ### Step 4: Add Request Headers (Optional) Add custom headers to include in the request: **Example:** | Header | Value | | - | - | | Content-Type | application/json | | X-Custom-Header | test-value | These are **in addition to** injected headers from route config. ### Step 5: Add Request Body (Optional) For POST/PUT/PATCH requests, provide request body: **JSON:** ```json theme={"dark"} { "name": "John Doe", "email": "john@example.com", "status": "active" } ``` **Form Data:** ```text theme={"dark"} name=John+Doe&email=john@example.com ``` **Raw:** ```text theme={"dark"} Any raw text or data ``` ### Step 6: Select Environment (Optional) Choose which environment to test: * production (default) * staging * development * Custom environments This determines which environment override config is used. ### Step 7: Click "Send Test Request" KnoxCall will: 1. Apply route configuration 2. Inject secrets (if any) 3. Apply environment overrides (if any) 4. Apply method-specific config (if any) 5. Send request to backend 6. Return response ## Understanding Test Results ### Request Details Section Shows exactly what was sent to your backend: **Request URL:** ```text theme={"dark"} https://api.example.com/users/123?filter=active ``` **Request Method:** ```text theme={"dark"} POST ``` **Request Headers:** ```text theme={"dark"} Authorization: Bearer sk_live_EXAMPLE_abc123xyz789... Content-Type: application/json X-Custom-Header: test-value X-Forwarded-For: 192.168.1.1 ``` Notice: * `Authorization` header was injected from route config * Secret was decrypted and value inserted * Custom headers are included **Request Body:** ```json theme={"dark"} { "name": "John Doe", "email": "john@example.com", "api_key": "sk_live_abc123..." } ``` Notice: * `api_key` was injected from body template * Secret was decrypted ### Response Details Section Shows what your backend returned: **Status Code:** ```text theme={"dark"} 200 OK ``` **Response Headers:** ```text theme={"dark"} Content-Type: application/json X-Request-ID: req_abc123 Cache-Control: no-cache ``` **Response Body:** ```json theme={"dark"} { "id": 123, "name": "John Doe", "email": "john@example.com", "created_at": "2025-01-15T10:30:00Z" } ``` **Response Time:** ```text theme={"dark"} 245ms ``` ### Config Applied Section Shows which configuration was used: **Environment:** ```text theme={"dark"} production ``` **Method Config:** ```text theme={"dark"} POST method config (overrides global) ``` **Secrets Injected:** ```text theme={"dark"} - stripe_api_key → sk_live_abc... (in Authorization header) - database_url → postgres://... (in body) ``` **Rate Limit:** ```text theme={"dark"} Not enforced in test mode ``` **Signature:** ```text theme={"dark"} Not required in test mode ``` **Test mode bypasses some security checks:** * Rate limiting is not enforced * Client IP authorization is skipped * Signatures are not required This lets you test freely without hitting limits or needing signatures. ## Example Test Scenarios ### Scenario 1: Test Secret Injection **Goal:** Verify Stripe API key is injected correctly **Setup:** 1. Select method: POST 2. Path: `/charges` 3. Body: ```json theme={"dark"} { "amount": 1000, "currency": "usd" } ``` 4. Send request **Verify:** * Request headers show: `Authorization: Bearer sk_live_...` (actual key, not template) * Response from Stripe API (charge created) * No errors about authentication ### Scenario 2: Test Method-Specific Config **Goal:** Verify POST uses different headers than GET **Test GET:** 1. Method: GET 2. Path: `/users` 3. Send request 4. **Check:** Authorization header uses `read_key` **Test POST:** 1. Method: POST 2. Path: `/users` 3. Body: `{"name": "Test"}` 4. Send request 5. **Check:** Authorization header uses `write_key` ### Scenario 3: Test Environment Override **Goal:** Verify staging environment uses staging URL **Setup:** 1. Method: GET 2. Path: `/health` 3. Environment: **staging** 4. Send request **Verify:** * Request URL shows staging URL: `https://staging-api.example.com/health` * Not production URL ### Scenario 4: Test Query Param Variables **Goal:** Verify `{{var:filter}}` is replaced correctly **Setup:** 1. Method: GET 2. Path: `/users` 3. Query params: * `filter` = `active` 4. Send request **Verify:** * Request headers show: `X-Filter: active` (variable replaced) * Not: `X-Filter: {{var:filter}}` (literal template) ### Scenario 5: Test Error Handling **Goal:** See how backend handles invalid data **Setup:** 1. Method: POST 2. Path: `/users` 3. Body: ```json theme={"dark"} { "email": "invalid-email" } ``` 4. Send request **Verify:** * Status code: 400 Bad Request * Response body shows validation error * Error message is clear ## Common Test Cases ### ✅ Before Going Live Checklist Test these scenarios before sending real traffic: * [ ] **GET request works** * Returns expected data * Correct headers injected * Secrets work * [ ] **POST request works** * Creates resource successfully * Body template works * Secrets injected correctly * [ ] **PUT/PATCH request works** * Updates resource * Idempotency works (if applicable) * [ ] **DELETE request works** * Deletes resource * Correct permissions * [ ] **Test each environment** * Production URL * Staging URL * Development URL * [ ] **Test method configs** * Each method uses correct config * Secrets differ per method * [ ] **Test error cases** * Invalid data returns 400 * Missing auth returns 401 * Not found returns 404 * [ ] **Test with real data** * Not just test values * Actual production-like payloads ## Troubleshooting with Route Tester ### Issue: "Connection refused" **Possible causes:** * Target URL is wrong * Backend server is down * Firewall blocking KnoxCall IPs **Debug:** 1. Check target URL in route config 2. Verify backend is running: `curl https://your-backend.com/health` 3. Check backend firewall allows KnoxCall IPs ### Issue: "401 Unauthorized" **Possible causes:** * Secret not injected * Wrong secret value * Secret expired **Debug:** 1. Check "Request Headers" section 2. Verify `Authorization` header present 3. Check secret value is correct (not template literal) 4. Test secret directly with backend ### Issue: Template Not Replaced **Symptoms:** Backend receives `{{secret:api_key}}` literal **Causes:** * Secret doesn't exist * Typo in secret name * Wrong template syntax **Debug:** 1. Check "Secrets Injected" section 2. Verify secret exists: **Secrets** page 3. Check spelling: `{{secret:api_key}}` not `{{api_key}}` ### Issue: "502 Bad Gateway" **Possible causes:** * Backend took too long (timeout) * Backend returned invalid response * Backend crashed **Debug:** 1. Check response time (>30 seconds?) 2. Check backend logs for errors 3. Test backend directly: `curl https://your-backend.com/endpoint` ### Issue: Wrong Environment Used **Symptoms:** Request went to production instead of staging **Debug:** 1. Check environment dropdown selection 2. Verify environment override exists 3. Check "Config Applied" section shows correct environment ## Generate Code Snippets The Route Tester has a **Code** tab that generates a ready-to-run snippet for calling the route from your own application. It is useful for copying a working request out of the tester and into your codebase, CI/CD pipeline, or terminal. 1. Open the Route Tester 2. Switch to the **Code** tab 3. Pick a language: cURL, JavaScript, Python, PHP, Go, Java, C#, Ruby, or C++ 4. Click the copy button to copy the snippet to your clipboard The generated snippet reflects your current method, path, request body, and environment selection, and includes the correct KnoxCall headers (`x-knoxcall-key`, `x-knoxcall-route`, and `x-knoxcall-environment` when a non-base environment is selected). The cURL snippet looks like: ```bash theme={"dark"} curl -X POST 'https://your-slug.knoxcall.com/api/users' \ -H 'x-knoxcall-key: $KNOXCALL_API_KEY' \ -H 'x-knoxcall-route: ' \ -H 'Content-Type: application/json' \ -d '{"name":"John"}' ``` ## Best Practices ### 1. Test Before Deploy Always test routes before sending production traffic. ### 2. Test All Methods Don't just test GET - test POST, PUT, DELETE too. ### 3. Test All Environments Verify dev, staging, prod all work. ### 4. Test Error Cases Don't only test happy path - test failures too. ### 5. Save Test Cases Document test cases in route description or wiki. ### 6. Test After Changes Re-test after changing: * Route config * Secrets * Environment overrides * Backend URL ### 7. Use Real Data Test with actual production-like data, not just `{"test": "data"}`. ### 8. Check Response Times Monitor how long requests take. Optimize if >1 second. ## Limitations ### Rate Limiting Bypassed Test mode doesn't enforce rate limits. Don't use for load testing. ### IP Authorization Skipped Test requests don't check client IP whitelist. ### Signatures Not Required Test mode doesn't require HMAC signatures. ### Real Backend Impact Test requests **actually hit your backend**. They're not simulated. **Test requests are real!** They actually call your backend API. Be careful testing: * DELETE requests (will delete data!) * POST requests (will create data) * Payment endpoints (will charge real money!) Use test/staging environments when possible. ## Related Features * **Method Configs**: Test different configs per HTTP method * **Environments**: Test staging before production * **Secrets**: Verify secrets inject correctly * **Logs**: View historical requests and replay them ## Next Steps Test method-specific configurations Test different environments View real request logs Verify secret injection *** * **Level**: beginner * **Time**: 5 minutes `testing`, `debugging`, `routes`, `tools` # What are Routes? Source: https://docs.knoxcall.com/essentials/routes/what-are-routes A quick introduction to routes - the foundation of API proxying in KnoxCall. # What are Routes? A **route** is a smart proxy that forwards API requests from your clients to your backend services. Think of it as a secure middleman that adds security, monitoring, and control. ## The Basic Concept ```text theme={"dark"} Your Client → KnoxCall Route → Your Backend API ``` Instead of calling your backend directly, clients call KnoxCall. KnoxCall validates the request, applies security rules, injects secrets, logs everything, then forwards to your backend. ## Why Use Routes? Routes solve common API problems: ✅ **Hide backend URLs** - Clients don't know your real backend URLs ✅ **Centralize credentials** - API keys stored securely, not in code ✅ **Monitor everything** - Log all requests and responses ✅ **Add security** - Rate limiting, IP whitelisting, signatures ✅ **Manage environments** - Different configs for dev/staging/prod ## How It Works **Step 1: Client makes request** ```bash theme={"dark"} curl https://YOUR-SUBDOMAIN.knoxcall.com/api/users \ -H "x-knoxcall-route: my-route" \ -H "Authorization: Bearer $TOKEN" ``` **Step 2: KnoxCall processes** * Validates the OAuth token, API key, or IP address * Checks permissions * Injects secrets (like API keys) * Applies rate limits **Step 3: Forwards to backend** ```http theme={"dark"} GET https://your-backend.com/api/users Authorization: Bearer SECRET_FROM_VAULT ``` **Step 4: Returns response** * Logs request and response * Triggers alerts if needed * Returns to client ## Required Headers Every request needs: ```bash theme={"dark"} x-knoxcall-route: YOUR_ROUTE_NAME ``` And either: ```bash theme={"dark"} Authorization: Bearer kc_... # OAuth access token (the SDK sends this for you) # OR x-knoxcall-key: tk_... # Legacy API key # OR # Your IP whitelisted # IP-based auth ``` ## URL Structure ```text theme={"dark"} https://YOUR-SUBDOMAIN.knoxcall.com/your/api/path ^^^^^^^^^^^^^^ tenant slug ``` * **Subdomain**: Your tenant slug — identifies your tenant (shown in dashboard) * **Path**: Passed through to backend unchanged ## Common Use Cases ### Webhook Forwarding ```text theme={"dark"} Stripe Webhook → KnoxCall → Your Server - Log every webhook - Verify signatures - Retry on failure ``` ### API Proxy ```text theme={"dark"} Mobile App → KnoxCall → Backend API - Hide backend URL - Manage API keys securely - Monitor usage ``` ### Microservices ```text theme={"dark"} Frontend → KnoxCall → { /users → User Service /orders → Order Service /payments → Payment Service } ``` ## Quick Example **1. Create a route:** * Name: `my-api` * Target: `https://api.example.com` **2. Make a request:** Using the [SDK](/sdks/overview) (after [`knoxcall login`](/api-reference/authentication)): ```typescript theme={"dark"} import { KnoxCall } from "@knoxcall/sdk"; const client = new KnoxCall(); // credentials from `knoxcall login` const resp = await client.call("my-api", { path: "/users" }); ``` Or raw curl — `$TOKEN` is a minted OAuth access token (see [Authentication](/api-reference/authentication)): ```bash theme={"dark"} curl https://acme.knoxcall.com/users \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: my-api" ``` **3. KnoxCall forwards to:** ```text theme={"dark"} https://api.example.com/users ``` That's it! Your route is working. ## What's Next? Step-by-step guide to create a route Configure headers, secrets, and more Test your routes with curl Rate limiting, method configs, transformations *** **Keep it simple:** Start with a basic route, then add advanced features as needed. # Creating Secrets Source: https://docs.knoxcall.com/essentials/secrets/creating-secrets Step-by-step guide to creating and managing encrypted secrets in KnoxCall. Learn about string secrets, OAuth2, and best practices. # Creating Secrets Learn how to create and manage encrypted secrets for API keys, passwords, and tokens. *** ## What You'll Learn * How to create string secrets (API keys, passwords) * How to create OAuth2 secrets (auto-refreshing tokens) * Naming conventions and best practices * Secret versioning and rotation *** ## Creating a String Secret String secrets are the most common type - used for API keys, passwords, tokens, and any sensitive text value. ### Step 1: Navigate to Secrets 1. Click **Secrets** in the sidebar 2. The Secrets page shows a **two-level hierarchy**: collections at the top level, secrets within each collection. Click a collection to see its secrets. Secrets not assigned to a collection appear under **Uncollected**. 3. Click **Add Secret** (at the top level or within a collection) ### Step 2: Choose Secret Type Select **String Secret** *For OAuth2 tokens, see [OAuth2 Secrets](#creating-oauth2-secrets) below.* ### Step 3: Enter Secret Details #### Secret Name (Required) The name you'll use to reference this secret in routes. **Format:** lowercase with underscores **Good examples:** ```text theme={"dark"} stripe_prod_key sendgrid_api_key database_password github_webhook_secret twilio_auth_token printnode_api_key ``` **Bad examples:** ```text theme={"dark"} secret1 (not descriptive) StripeKey (use lowercase) stripe-key (use underscores, not hyphens) key (too generic) ``` **Why naming matters:** You'll reference secrets in templates like this: ```text theme={"dark"} {{secret:stripe_prod_key}} ``` Clear names make your configurations self-documenting. #### Secret Value (Required) The actual sensitive value to encrypt and store. **Examples:** ```text theme={"dark"} API keys: sk_live_EXAMPLE_abc123xyz789... Passwords: MySecureP@ssw0rd!2024 Tokens: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... URLs: postgres://user:pass@host:5432/db Webhook secrets: whsec_abc123xyz789... ``` **Important:** * Copy from source carefully (no extra spaces!) * Value is encrypted immediately after saving * You won't be able to view plaintext again ### Step 4: Save Click **Create Secret** ✅ **Done!** Your secret is now: * Encrypted with AES-256-GCM * Stored securely * Ready to use in routes ⚠️ **Important:** The plaintext value is now gone from the UI. If you need to reference it later, you'll need to get it from the original source. *** ## Creating OAuth2 Secrets OAuth2 secrets are special - they **automatically refresh** access tokens when they expire. ### When to Use OAuth2 Secrets Use OAuth2 for services that require token refresh: * ✅ Google APIs (Gmail, Drive, Sheets) * ✅ Microsoft Graph (Office 365, OneDrive) * ✅ Shopify * ✅ HubSpot * ✅ Salesforce * ✅ Slack ### Step 1: Choose OAuth2 Secret Type When creating secret, select **OAuth2 Secret** ### Step 2: Fill in OAuth2 Details #### Secret Name ```text theme={"dark"} google_oauth_token shopify_access_token hubspot_oauth_creds ``` #### OAuth2 Configuration **Client ID:** ```text theme={"dark"} 123456789.apps.googleusercontent.com ``` *From your OAuth app settings* **Client Secret:** ```text theme={"dark"} GOCSPX-abc123xyz789... ``` *From your OAuth app settings* **Provider:** ```text theme={"dark"} google ``` *One of the supported OAuth2 providers (e.g. `google`, `microsoft`)* **Scopes (optional):** ```text theme={"dark"} https://www.googleapis.com/auth/gmail.readonly ``` *Defaults to the provider's default scopes if omitted* **Auth URL / Token URL (optional):** ```text theme={"dark"} https://oauth2.googleapis.com/token ``` *Override the provider's authorize/token endpoints; defaults are filled in for you* You do **not** paste in an access token, refresh token, or expiry at creation time. KnoxCall stores only your OAuth app's `client_id`/`client_secret` (or an `mtls_certificate_id`) plus the endpoint config. The secret is created with `connection_status: "not_connected"`, and the response includes a minted `redirect_uri`. ### Step 3: Save and Connect Click **Create Secret**. The response includes a `redirect_uri` on the fixed `oauth.knoxcall.com/callback` host. ✅ **Complete the OAuth flow** to obtain tokens. Visit the authorization URL, approve access at the provider, and the provider redirects back to the minted `redirect_uri`. KnoxCall then exchanges the code for tokens and flips `connection_status` to connected. Once connected, KnoxCall will: * Monitor token expiry * Refresh before expiration using the stored refresh token * Update the access token automatically * Log refresh events *** ## Secret Naming Conventions ### Pattern: `{service}_{environment}_{type}` **Examples:** #### By Service ```text theme={"dark"} stripe_prod_key stripe_staging_key sendgrid_prod_api_key twilio_dev_token ``` #### By Environment ```text theme={"dark"} database_prod_password database_staging_password database_dev_password ``` #### By Purpose ```text theme={"dark"} webhook_secret_github webhook_secret_shopify api_key_printnode api_key_openai ``` ### Multiple Secrets for Same Service When you need different keys for different purposes: ```text theme={"dark"} stripe_prod_live_key (live transactions) stripe_prod_restricted_key (read-only) stripe_test_key (testing) ``` *** ## Environment-Specific Secrets Create separate secrets for each environment: ### Production Secrets ```text theme={"dark"} Name: stripe_prod_key Value: sk_live_abc123... Description: Stripe production - live transactions Tags: production, payment, critical ``` ### Staging Secrets ```text theme={"dark"} Name: stripe_staging_key Value: sk_test_xyz789... Description: Stripe staging - test mode Tags: staging, payment ``` ### Development Secrets ```text theme={"dark"} Name: stripe_dev_key Value: sk_test_dev123... Description: Stripe development - local testing Tags: development, payment ``` **Then use in route overrides:** ```text theme={"dark"} Production environment: Header: Authorization: Bearer {{secret:stripe_prod_key}} Staging environment: Header: Authorization: Bearer {{secret:stripe_staging_key}} Development environment: Header: Authorization: Bearer {{secret:stripe_dev_key}} ``` *** ## Best Practices ### ✅ Do **1. Use descriptive names** ```text theme={"dark"} ✅ stripe_prod_live_key ❌ key1 ``` **2. Document with descriptions** ```text theme={"dark"} ✅ "Stripe production API key - rotate every 90 days - expires Mar 2025" ❌ (no description) ``` **3. Separate by environment** ```text theme={"dark"} ✅ stripe_prod_key, stripe_staging_key, stripe_dev_key ❌ stripe_key (used everywhere) ``` **4. Tag appropriately** ```text theme={"dark"} ✅ Tags: production, payment, critical ❌ (no tags) ``` **5. Version for rotation** ```text theme={"dark"} Secret v1: stripe_prod_key (old) Secret v2: stripe_prod_key (new, rotated) ``` **6. Use OAuth2 type when applicable** ```text theme={"dark"} ✅ OAuth2 secret for Google APIs (auto-refresh) ❌ String secret that expires (manual update needed) ``` ### ❌ Don't **1. Don't commit secrets to git** ```text theme={"dark"} ❌ .env file in repository ✅ Store in KnoxCall secrets ``` **2. Don't reuse across environments** ```text theme={"dark"} ❌ Same key for prod and dev ✅ Separate keys per environment ``` **3. Don't use generic names** ```text theme={"dark"} ❌ api_key, secret, password ✅ stripe_prod_key, database_prod_password ``` **4. Don't skip descriptions** ```text theme={"dark"} ❌ No description ✅ "Production database - rotate quarterly - last rotated Jan 2025" ``` **5. Don't expose test keys in production** ```text theme={"dark"} ❌ stripe_test_key in production environment ✅ stripe_prod_key in production ``` *** ## Secret Security ### How Secrets Are Stored 1. **Encryption:** AES-256-GCM (military-grade) 2. **Envelope encryption:** Each secret has unique encryption key 3. **Master key:** Stored separately, never exposed 4. **Integrity:** SHA-256 checksum verification ### Who Can Access Secrets **View encrypted secrets:** * All team members with access **View plaintext values:** * **Nobody** - not even admins! * Plaintext only visible when: * Initially created (one-time viewing) * Decrypted server-side during requests * Never shown in logs or UI **Use secrets in routes:** * Anyone with route edit permissions ### Secret Transmission When requests use secrets: 1. Client makes request to KnoxCall 2. KnoxCall loads route config 3. Sees `{{secret:stripe_key}}` template 4. Decrypts secret **server-side** 5. Injects into request to backend 6. Client never sees plaintext! *** ## Common Use Cases ### 1. Payment APIs **Stripe:** ```text theme={"dark"} Name: stripe_prod_key Value: sk_live_EXAMPLE_abc123xyz789... Description: Stripe production secret key ``` **Usage in route:** ```json theme={"dark"} { "Authorization": "Bearer {{secret:stripe_prod_key}}" } ``` ### 2. Email Services **SendGrid:** ```text theme={"dark"} Name: sendgrid_api_key Value: SG.abc123xyz789... Description: SendGrid API key for transactional emails ``` **Usage:** ```json theme={"dark"} { "Authorization": "Bearer {{secret:sendgrid_api_key}}" } ``` ### 3. SMS Services **Twilio:** ```text theme={"dark"} Name: twilio_auth_token Value: abc123xyz789... Description: Twilio auth token for SMS ``` **Usage:** ```json theme={"dark"} { "Authorization": "Basic {{secret:twilio_auth_token}}" } ``` ### 4. Database Credentials **PostgreSQL:** ```text theme={"dark"} Name: database_prod_url Value: postgres://user:pass@host:5432/db Description: Production database connection string ``` **Usage in body:** ```json theme={"dark"} { "connection_string": "{{secret:database_prod_url}}" } ``` ### 5. Webhook Secrets **GitHub:** ```text theme={"dark"} Name: github_webhook_secret Value: whsec_abc123... Description: GitHub webhook verification secret ``` **Usage:** ```json theme={"dark"} { "X-Hub-Signature": "{{secret:github_webhook_secret}}" } ``` ### 6. Printing Services **PrintNode:** ```text theme={"dark"} Name: printnode_api_key Value: abc123xyz789... Description: PrintNode API key for cloud printing ``` **Usage:** ```json theme={"dark"} { "Authorization": "Basic {{secret:printnode_api_key}}" } ``` *** ## Secret Versioning KnoxCall tracks secret versions for safe rotation. ### Creating a New Version When rotating a secret: 1. Go to **Secrets** 2. Click on secret name 3. Click **Add New Version** 4. Enter new value 5. Save **What happens:** * New version becomes active immediately * Old version preserved (for rollback) * All routes using secret now use new value * Zero downtime! ### Viewing Version History 1. Click secret name 2. Go to **Version History** tab 3. See all versions with timestamps 4. Can rollback if needed *** ## Troubleshooting ### Secret Not Found in Templates **Error:** `Secret 'stripe_key' not found` **Causes:** * Typo in secret name * Secret was deleted * Wrong syntax in template **Solution:** 1. Check secret exists: **Resources** → **Secrets** 2. Verify spelling matches exactly 3. Check template syntax: `{{secret:name}}` ### Cannot View Secret Value **Symptom:** Want to see what the value is. **Cause:** Plaintext never shown after creation (security feature). **Solution:** * Get value from original source * Or create new version with known value ### Secret Not Injecting **Symptom:** Backend receives literal `{{secret:stripe_key}}` text. **Causes:** * Template syntax error * Secret doesn't exist * Injection disabled **Debug:** 1. Check logs - what was actually sent? 2. Verify template syntax is correct 3. Test secret exists and name matches 4. Verify inject headers is enabled on route *** ## Quick Reference | Task | Steps | | - | - | | **Create string secret** | Resources → Secrets → Add Secret → String | | **Create OAuth2 secret** | Resources → Secrets → Add Secret → OAuth2 | | **Rotate secret** | Click secret → Add New Version | | **View versions** | Click secret → Version History | | **Delete secret** | Click secret → Delete (⚠️ permanent) | *** ## Next Steps How to inject secrets into routes Complete secrets guide Set up OAuth2 auto-refresh Best practices for rotating secrets *** **Security Reminder:** Never commit secrets to git, share via email, or hardcode in applications. Always use KnoxCall's encrypted secret storage. # Environment-Specific Secret Values Source: https://docs.knoxcall.com/essentials/secrets/environment-specific-secrets Configure different secret values for production, staging, and development environments. One secret, multiple values with automatic environment-based resolution. # Environment-Specific Secret Values Use a single secret with different values for each environment. KnoxCall automatically uses the correct value based on the route's active environment—no manual switching required. ## The Problem Traditional approach requires separate secrets per environment: ```text theme={"dark"} ❌ Old Way: 3 Secrets ├─ stripe_production_key → sk_live_abc123 ├─ stripe_staging_key → sk_test_staging_456 └─ stripe_development_key → sk_test_dev_789 Route config must change per environment: Production: {{secret:stripe_production_key}} Staging: {{secret:stripe_staging_key}} Development: {{secret:stripe_development_key}} ``` **Problems:** * Accidental use of production keys in staging * Manual route config updates when switching environments * Difficult to audit which env uses which key * Secret sprawl (3x secrets) ## The Solution One secret, multiple environment-specific values: ```text theme={"dark"} ✅ New Way: 1 Secret stripe_api_key ├─ production: sk_live_abc123 ├─ staging: sk_test_staging_456 └─ development: sk_test_dev_789 Route config stays the same: All environments: {{secret:stripe_api_key}} ``` **Benefits:** * ✅ Automatic environment-based resolution * ✅ Hard fail if environment value missing (prevents accidents) * ✅ Simplified route configuration * ✅ Clear audit trail per environment ## How It Works ### Step 1: Create Secret Create a secret normally: ```text theme={"dark"} Name: stripe_api_key Type: String ``` Leave the initial value empty—we'll set environment-specific values next. ### Step 2: Configure Environment Values In the secret detail page: 1. **Select environment** from dropdown (top right) 2. **Enter value** for that environment 3. **Save** 4. **Repeat** for other environments **Example:** ```text theme={"dark"} Environment: production Value: sk_live_EXAMPLE_4eC39HqLyjW [Save] Environment: staging Value: sk_test_staging_BQokikJOvBiI2HlWgH4olfQ2 [Save] Environment: development Value: sk_test_dev_4eC39HqLyjWDarjtT1zdp7dc [Save] ``` ### Step 3: Reference in Route Use the secret in your route config: ```json theme={"dark"} { "headers": { "Authorization": "Bearer {{secret:stripe_api_key}}" } } ``` ### Step 4: Automatic Resolution When a request comes in: ```text theme={"dark"} 1. Client calls route 2. Route has active environment: "production" 3. KnoxCall looks up stripe_api_key environment config 4. Finds production value: sk_live_EXAMPLE_4eC39HqLyjW 5. Injects value into request 6. Proxies to Stripe API ✅ ``` If route is in staging environment: ```text theme={"dark"} Same route, staging environment: → Uses sk_test_staging_BQokikJOvBiI2HlWgH4olfQ2 automatically ``` ## Hard Fail Behavior **Safety feature**: If a secret lacks a value for the requested environment, the request **fails immediately**. ```text theme={"dark"} Scenario: - Secret: stripe_api_key - Configured environments: production, staging - Route environment: development ❌ Result: The proxy refuses to inject the secret and the request is blocked. The client receives: HTTP 401 Unauthorized { "error": "Unauthorized" } ``` The detailed reason — `Secret 'stripe_api_key' () has no value configured for environment 'development'` — is recorded server-side in the request log (`api_requests.error`), not returned to the caller. Error responses are normalized so callers can't enumerate tenant or secret configuration from the status code or message. **Why no fallback?** * Prevents accidental production key usage in dev/staging * Forces explicit configuration * Makes missing config obvious during testing * Complies with principle of least privilege **Solution:** Add the missing environment value or change route's active environment. ## Supported Secret Types Environment-specific values work with **all secret types**: ### String Secrets ```text theme={"dark"} API Key: stripe_api_key ├─ production: sk_live_abc123 ├─ staging: sk_test_staging_456 └─ development: sk_test_dev_789 ``` ### OAuth2 Secrets ```text theme={"dark"} OAuth Client: google_oauth ├─ production: │ client_id: prod-client-id.apps.googleusercontent.com │ client_secret: GOCSPX-prod-abc123 │ ├─ staging: │ client_id: staging-client-id.apps.googleusercontent.com │ client_secret: GOCSPX-staging-def456 │ └─ development: client_id: dev-client-id.apps.googleusercontent.com client_secret: GOCSPX-dev-ghi789 ``` ### Certificate Secrets (mTLS) ```text theme={"dark"} Client Certificate: bank_mtls_cert ├─ production: │ cert: -----BEGIN CERTIFICATE----- (prod cert) │ key: -----BEGIN PRIVATE KEY----- (prod key) │ ├─ staging: │ cert: -----BEGIN CERTIFICATE----- (staging cert) │ key: -----BEGIN PRIVATE KEY----- (staging key) │ └─ development: cert: -----BEGIN CERTIFICATE----- (dev cert) key: -----BEGIN PRIVATE KEY----- (dev key) ``` ## Advanced Usage ### Partial Environment Coverage You can configure only the environments you need: ```text theme={"dark"} Secret: internal_api_key Configured: ✅ production ✅ staging ❌ development (not configured) Routes in development environment: → Will fail (hard fail) → Forces you to add dev value or use different secret ``` ### Duplicating Environments Clone an existing environment's value: 1. Go to secret detail page 2. Select source environment (e.g., "production") 3. Click **Duplicate to another environment** 4. Choose target environment (e.g., "staging") 5. **Modify** the duplicated value if needed 6. Save **Use case:** Copy production config to staging, then change specific values. ### Deleting Environment Values **Protected deletion**: Cannot delete an environment value if any route is actively using it in that environment. ```text theme={"dark"} Scenario: - Secret: stripe_api_key has production value - Route "Stripe Charges" is active in production environment - Try to delete production value Result: ❌ Error: Cannot delete - 1 route using this secret in production Routes: Stripe Charges (route_abc123) ``` **Solution:** 1. Switch routes to different environment, OR 2. Update routes to use different secret, OR 3. Delete the routes first ### Viewing All Environments In the secret detail page, see all configured environments at a glance: ```text theme={"dark"} Secret: stripe_api_key Environment Status: ✅ production (configured, 4 routes using) ✅ staging (configured, 2 routes using) ✅ development (configured, 0 routes using) ⚠️ testing (not configured) ``` ## API Reference ### Get Secret Environments ```http theme={"dark"} GET /admin/secrets/{secret_id}/environments Response: { "secret_id": "uuid", "secret_name": "stripe-prod-key", "secret_type": "string", "base_environment": "production", "environments": [ { "environment_name": "production", "created_at": "2025-01-15T10:00:00Z", "updated_at": "2025-01-20T14:30:00Z" }, { "environment_name": "staging", "created_at": "2025-01-15T10:05:00Z", "updated_at": "2025-01-15T10:05:00Z" } ] } ``` Note: Values are **never** returned via API for security. ### Create/Update Environment Value ```http theme={"dark"} PUT /admin/secrets/{secret_id}/environments/{environment_name} Body (String secret only): { "value": "sk_test_abc123" } Note: This endpoint only accepts `string` secrets. OAuth2 and certificate secrets have their own dedicated endpoints for managing environment-level values. ``` ### Delete Environment Value ```http theme={"dark"} DELETE /admin/secrets/{secret_id}/environments/{environment_name} Response: { "ok": true } Or if in use by routes: { "error": "Cannot remove environment 'production' from this secret. It is used by the following routes in that environment: stripe-charges. Remove the secret from those routes first.", "routes": [{ "id": "uuid", "name": "stripe-charges" }] } ``` ### Duplicate Environment ```http theme={"dark"} POST /admin/secrets/{secret_id}/environments/{source_env}/duplicate Body: { "target_environment": "development" } Response: { "ok": true, "environment": "development", "message": "Successfully created environment 'development' from 'staging'" } ``` ## Migration Guide ### From Naming Convention to Environment Configs **Before:** ```text theme={"dark"} Secrets: - stripe_production_key - stripe_staging_key - stripe_development_key Route config (production): { "Authorization": "Bearer {{secret:stripe_production_key}}" } ``` **After:** ```text theme={"dark"} Secret: stripe_api_key ├─ production: (value from stripe_production_key) ├─ staging: (value from stripe_staging_key) └─ development: (value from stripe_development_key) Route config (all environments): { "Authorization": "Bearer {{secret:stripe_api_key}}" } ``` **Steps:** 1. **Create new secret**: `stripe_api_key` (no initial value) 2. **Copy values**: * Environment: production → Paste value from `stripe_production_key` * Environment: staging → Paste value from `stripe_staging_key` * Environment: development → Paste value from `stripe_development_key` 3. **Update routes**: * Change `{{secret:stripe_production_key}}` to `{{secret:stripe_api_key}}` * Remove environment-specific references 4. **Test** in staging first 5. **Deploy** to production 6. **Delete old secrets** after 48 hours ## Best Practices ### ✅ Do This 1. **Use environment configs for new secrets** * Simpler, safer, better audit trail 2. **Configure all environments you use** * Don't leave gaps (hard fail protects you) 3. **Test in staging first** * Verify correct values loaded per environment 4. **Use descriptive names** * `stripe_api_key` not `secret_42` * Easier to manage across environments 5. **Document your environments** * Keep a list of which environments exist * Standard: production, staging, development ### ❌ Avoid This 1. **Don't mix approaches for same service** * Either use env configs OR naming convention * Don't use both for Stripe (confusing) 2. **Don't delete environment values carelessly** * Check if routes are using them first * System prevents deletion, but verify manually 3. **Don't share values across environments** * Never use production keys in staging * Even if system allows it, don't do it 4. **Don't skip testing after migration** * Always test in dev/staging first * Verify hard fail works as expected ## Troubleshooting ### "Secret not configured for environment" **Problem:** ```text theme={"dark"} Route fails with: "Secret 'api_key' not configured for environment 'staging'" ``` **Solutions:** 1. Add the missing environment value 2. Change route's active environment 3. Use a different secret that has the environment ### Can't delete environment value **Problem:** ```text theme={"dark"} Cannot delete - 3 routes using this secret in production ``` **Solution:** ```sql theme={"dark"} -- Find routes using this secret SELECT r.name, r.id FROM routes r JOIN route_environment_configs rec ON r.id = rec.route_id WHERE rec.environment_name = 'production' AND rec.active = true; -- Then either: 1. Switch those routes to different environment 2. Update routes to use different secret 3. Accept that you can't delete (it's in use!) ``` ### Environment dropdown not showing **Problem:** Can't see environment selector in secret detail page **Possible causes:** 1. Secret type doesn't support environments (shouldn't happen - all types do) 2. Browser cache issue - hard refresh (Ctrl+F5) 3. Old UI version - ensure admin-ui is up to date **Solution:** ```bash theme={"dark"} cd admin-ui && npm run build pm2 restart knoxcall ``` ### Wrong value being used **Problem:** Route in staging is using production value **Debug steps:** 1. Check route's active environment (top right dropdown) 2. Verify secret has value for that environment 3. Check route config uses correct secret name 4. Test with a different route **Common cause:** Route's active environment is set to "production" even though you think it's staging. ## Security Considerations ### Encryption All environment values are encrypted individually: ```text theme={"dark"} Secret: stripe_api_key Each environment value: ├─ Encrypted with unique data encryption key (DEK) ├─ DEK encrypted with master key (KEK) ├─ Stored: ciphertext + IV + auth tag └─ Decrypted only when route resolves secret ``` ### Audit Trail Every environment value change is logged: ```sql theme={"dark"} SELECT resource_id, -- the secret's id action, -- e.g. 'create', 'update', 'rotate', 'delete' details, -- JSON: { name, environment, ... } user_id, -- set for dashboard users (null for API-key access) ip_address, created_at FROM audit_logs WHERE resource_type = 'secret' AND resource_id = 'sec_abc123'; ``` ### Access Control * Only tenant admins can view/edit secret environment configs * The client API requires a KnoxCall OAuth access token or API key (`Authorization: Bearer `; legacy API keys may also use the `x-api-key` header — see [Authentication](/api-reference/authentication)), not a session JWT * No way to retrieve values via API (write-only) * Environment values never logged or transmitted in plaintext ### Control mappings Which requirement in your own programme this feature speaks to. These are mappings, not attestations KnoxCall holds — see the [trust page](https://knoxcall.com/trust). * **PCI DSS**: Separate production and test credentials * **SOC 2**: Audit trail of secret changes per environment * **GDPR**: Environment isolation for data protection * **HIPAA**: Segregation of production and non-production ## Next Steps Learn how to create secrets in KnoxCall Reference secrets in route configurations Understand KnoxCall environments Best practices for rotating secrets *** * **Level**: Intermediate * **Time**: 10 minutes * **Prerequisites**: Basic understanding of secrets and environments `secrets`, `environments`, `configuration`, `security`, `credentials` # Secret Rotation and Management Source: https://docs.knoxcall.com/essentials/secrets/secrets-overview Learn how to rotate secrets safely, manage per-environment credentials, and organize secrets effectively. # Secret Rotation and Management Learn how to rotate secrets safely, organize them effectively, and manage per-environment credentials. ## Why Rotate Secrets? **Security reasons:** * Limit blast radius if compromised * Meet compliance requirements (PCI DSS, SOC 2) * Best practice for production systems **Recommended schedule:** * **Critical secrets** (production API keys): Every 30 days * **Standard secrets**: Every 90 days * **Database passwords**: Every 90 days * **OAuth tokens**: When revoked by provider ## Safe Rotation Process ### Step-by-Step **1. Create new secret** with different name: ```text theme={"dark"} Old: stripe_api_key New: stripe_api_key_v2 ``` **2. Update routes** to use new secret: ```json theme={"dark"} { "Authorization": "Bearer {{secret:stripe_api_key_v2}}" } ``` **3. Test thoroughly** in staging first **4. Deploy to production** **5. Monitor** for 24-48 hours **6. Delete old secret** when confirmed working ### Why This Works * Zero downtime * Easy rollback (keep old secret temporarily) * Clear audit trail ### What NOT to Do ❌ **Don't delete the old secret immediately** ❌ **Don't delete immediately** * Keep old secret for 48 hours * Allows rollback if issues ## Environment-Specific Secrets KnoxCall supports two approaches for managing secrets across environments: ### Approach 1: Environment Configurations (Recommended) **Best for**: Single secret with different values per environment Create one secret with environment-specific values: ```text theme={"dark"} Secret: stripe_api_key Environments: production: sk_live_EXAMPLE_abc123xyz789 staging: sk_test_staging_456def development: sk_test_dev_789ghi ``` **How to configure:** 1. Create a secret: `stripe_api_key` 2. In the secret detail page, select environment from dropdown 3. Add value for each environment: * Switch to "production" → Enter live key * Switch to "staging" → Enter test key * Switch to "development" → Enter dev key **Using in routes:** ```json theme={"dark"} { "Authorization": "Bearer {{secret:stripe_api_key}}" } ``` **Behavior:** * Route in "production" environment → Uses production value * Route in "staging" environment → Uses staging value * **Hard fail if missing**: Route returns error if secret lacks the environment value * No fallbacks or defaults (prevents accidental production key usage in dev) **Benefits:** * ✅ One secret name across all environments * ✅ Impossible to accidentally use wrong key * ✅ Clear environment isolation * ✅ Simplified route configuration ### Approach 2: Naming Convention (Legacy) **Best for**: Backward compatibility or when you prefer separate secrets Create different secrets per environment: ```text theme={"dark"} Secrets: stripe_production_api_key stripe_staging_api_key stripe_development_api_key ``` **Production route:** ```json theme={"dark"} { "Authorization": "Bearer {{secret:stripe_production_api_key}}" } ``` **Staging route:** ```json theme={"dark"} { "Authorization": "Bearer {{secret:stripe_staging_api_key}}" } ``` **Drawbacks:** * ❌ Must update route config when changing environments * ❌ Possible to reference wrong secret in wrong environment * ❌ More secrets to manage ### Comparison | Feature | Environment Configs | Naming Convention | | - | - | - | | Secret count | 1 per service | 3+ per service | | Route config | Same everywhere | Different per env | | Safety | Hard fail if missing | Silent failure possible | | Ease of use | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | | Recommended | ✅ Yes | Legacy support | **Migration tip**: Use environment configs for new secrets, migrate old ones gradually ## Organization Strategies ### Group by Service ```text theme={"dark"} stripe_api_key stripe_webhook_secret stripe_publishable_key sendgrid_api_key sendgrid_webhook_key aws_access_key_id aws_secret_access_key aws_region ``` ### Group by Environment ```text theme={"dark"} production_stripe_key production_sendgrid_key production_database_url staging_stripe_key staging_sendgrid_key staging_database_url ``` Choose one strategy and be consistent! ## Multiple Secrets in One Route Inject multiple credentials: ```json theme={"dark"} { "Authorization": "Bearer {{secret:stripe_api_key}}", "X-Partner-Key": "{{secret:partner_api_key}}", "X-Database": "{{secret:database_url}}" } ``` All injected server-side, none exposed to client. ## Cleaning Up Secrets ### Find Unused Secrets To reduce attack surface, periodically remove secrets you no longer need, such as: * Test or temporary secrets * Secrets left over from completed rotations * Credentials for routes that have been deleted KnoxCall protects you from deleting a secret that is still in use: if a secret is still referenced by any route, the delete request is rejected with the name of the route that depends on it. Remove the reference from all routes first, then delete the secret. ## Common Patterns ### API Key Rotation ```text theme={"dark"} 1. Generate new key from provider (Stripe, SendGrid, etc) 2. Create secret: service_key_v2 3. Update routes to use v2 4. Test thoroughly 5. Delete old secret after 48h ``` ### Database Password Rotation ```text theme={"dark"} 1. Create new password in database 2. Create secret: db_password_v2 3. Update routes to use v2 4. Test all database connections 5. Remove old password from database 6. Delete old secret ``` ### OAuth Token Refresh ```text theme={"dark"} 1. Refresh token via OAuth flow 2. Create secret: service_oauth_token_v2 3. Update routes 4. Test authentication 5. Revoke old token with provider 6. Delete old secret ``` ## Troubleshooting **Routes failing after rotation:** * Verify new secret value is correct * Check all routes were updated * Look for cached old values * Roll back to old secret if needed **"Secret not found" errors:** * Check spelling (case-sensitive) * Verify secret exists in Secrets page * Confirm route is using correct name **Third-party API returns 401:** * New secret value might be wrong * API key might be expired/revoked * Test secret value directly with provider ## Rotation Checklist Use this checklist for each rotation: * [ ] Document which routes use this secret * [ ] Generate new credential from provider * [ ] Create new secret with v2 suffix * [ ] Update all routes in staging * [ ] Test thoroughly in staging * [ ] Update routes in production * [ ] Monitor for 48 hours * [ ] Verify no errors * [ ] Delete old secret * [ ] Update documentation * [ ] Schedule next rotation (calendar) ## Next Steps * Set up environments for per-env secrets * Learn about securing your routes * Configure monitoring and alerting **Need help?** Use the support chat! *** * **Level**: intermediate * **Time**: 10 minutes `secrets`, `security`, `rotation`, `management` # Sharing Secrets Source: https://docs.knoxcall.com/essentials/secrets/sharing-secrets Share an API key with another company like 1Password — except they can use it without ever seeing the raw value, only toward approved domains, and you can stop sharing instantly. # Sharing Secrets Share a secret with an external party the way you'd share a password in 1Password — but with three things a password manager can't do: * **They never see the value.** By default the recipient can *use* the secret (KnoxCall injects it into their API calls) but can never read the raw key. * **It's locked to approved destinations.** The secret can only be sent to hosts you choose (e.g. `api.stripe.com`). It cannot be exfiltrated to a URL the recipient controls. * **You can stop instantly.** Revoke a share and the recipient loses access on their very next request — no rotation, no waiting. Because there is a **single canonical copy** of the secret (yours), any change you make — a rotation, a new value — is instantly live for everyone you've shared it with. Nothing to re-send. *** ## How it works 1. From a secret's **Sharing** tab, you invite someone by email and pick: * an **access level** (below), * the **destination domains** the secret may be used against, * which **environment**'s value to share. 2. The recipient gets an email with a claim link. They are **never** added to your workspace — instead they sign up for (or sign in to) their **own** KnoxCall workspace, where a **linked secret** appears. 3. In their workspace, the linked secret behaves like any other secret in their routes — but it holds no value of its own. At request time KnoxCall resolves it to your canonical value, injects it, and (if it's a `use only` share) never exposes it. ## Access levels | Level | Can inject into routes | Can reveal the value | Can edit the value | | - | :-: | :-: | :-: | | **Use only** (default) | ✅ | ❌ | ❌ | | **Can view** | ✅ | ✅ (with step-up verification, every reveal) | ❌ | | **Can manage** | ✅ | ✅ (with step-up verification, every reveal) | ✅ (edits your canonical secret) | Revealing a shared value takes the same fresh step-up verification as revealing any secret in the recipient's own workspace (an authenticator code, a passkey or a security key, once per reveal), from the Owner, an Admin, or an Editor with **Reveal secrets** turned on. It is recorded as `plaintext.reveal` in **both** workspaces' audit logs before the value is decrypted: the recipient's entry names the person, and yours (the owner's) names the recipient workspace. If either entry cannot be recorded, the reveal is refused. `Use only` is the headline capability — grant the ability to *use* a credential without ever handing over the credential itself. ### Who can create a readable share Creating a **Can view** or **Can manage** share lets the recipient read the value in their own workspace, so it is a reveal. In the dashboard this is a **Class B reveal**: only an **Owner** or an **Admin** can do it (an Editor cannot, even with **Reveal secrets** turned on; that setting covers secret values, certificates and credentialed workflow tests only), and every use asks for a fresh second factor (an authenticator code, a passkey or a security key; an emailed code does not count). Each use writes a `plaintext.reveal` entry to the audit log **before** anything is shown, and counts against the same per-person reveal budget as showing a secret's value (60 per 5 minutes). If the audit entry cannot be written, or the budget cannot be counted, the request is refused rather than served. Shares are created in the dashboard only; the Management API (`/v1`) has no share endpoint. A **Use only** share is not a reveal: the recipient never sees the value and the domain lock bounds where it can be sent, so creating one is unchanged. ## Domain-locking Domain-locking is **required by default**. When you share a secret you choose the destinations it may be injected toward: * exact hostnames — `api.stripe.com` * a single wildcard label — `*.example.com` (matches `a.example.com`, not the apex) At request time, KnoxCall checks the resolved upstream host against this list **before sending anything**. If a recipient points a route at a host you didn't approve, the request is refused and the value is never transmitted. Choosing "any domain" is possible but is an explicit, audited opt-out — for a `use only` share it removes the only thing preventing the recipient from forwarding the value to themselves, so we don't recommend it. ## Revoking Open the **Sharing** tab and choose **Stop sharing** on any recipient. Access ends immediately — the check happens on every request, uncached. Deleting a secret that is actively shared prompts you to revoke those shares first (or to revoke-and-delete in one step). *** ## What "never see the value" actually means (the honest version) This is important to state plainly. Linked secret sharing is **recipient-opaque, platform-trusted, and destination-constrained** — it is **not** zero-knowledge: * **Recipient-opaque:** the value is never revealed to the recipient through any product surface, and it never leaves KnoxCall toward a destination you didn't approve. * **Platform-trusted:** KnoxCall's control plane decrypts your secret server-side to inject it (exactly as it does for your own routes today). The "never reveal" guarantee is an authorization + destination boundary, not a cryptographic one. * **Your kill-switch:** if you use **customer-managed keys (BYOK)**, sealing your KMS revokes every share instantly and cryptographically — nothing can be injected. In short: the recipient can *use* the credential against the destinations you allow, and can never see it or send it elsewhere — but they are trusting KnoxCall to hold the key, the same as you are. Linked secrets work with the classic proxy (Routes). Sharing string, OAuth2, and certificate secrets is supported. Live and Test are isolated: a Live share never resolves a Test secret. # Using Secrets in Routes Source: https://docs.knoxcall.com/essentials/secrets/using-secrets Complete guide to injecting secrets into routes. Learn template syntax, headers, body injection, and environment-specific secrets. # Using Secrets in Routes Learn how to inject encrypted secrets into your route requests using KnoxCall's template system. *** ## Template Syntax Secrets are injected using **template syntax** in your route configuration. ### Basic Format ```text theme={"dark"} {{secret:SECRET_NAME}} ``` **Components:** * `{{` - Opening delimiter * `secret:` - Type identifier (required) * `SECRET_NAME` - Your secret's name * `}}` - Closing delimiter ### Example If you have a secret named `stripe_prod_key`: ```json theme={"dark"} { "Authorization": "Bearer {{secret:stripe_prod_key}}" } ``` At runtime, KnoxCall replaces this with: ```json theme={"dark"} { "Authorization": "Bearer sk_live_EXAMPLE_abc123xyz789..." } ``` *** ## Injecting Secrets in Headers The most common use case - adding secrets to HTTP headers. ### Step 1: Edit Route 1. Go to **Routes** 2. Click on your route 3. Scroll to **Inject Headers** section ### Step 2: Add Header with Secret Click **Add Header** or edit the JSON directly: ```json theme={"dark"} { "Authorization": "Bearer {{secret:stripe_prod_key}}" } ``` ### Step 3: Save Click **Save Changes** ✅ All requests through this route now have the secret injected! ### Common Header Patterns #### Bearer Token ```json theme={"dark"} { "Authorization": "Bearer {{secret:api_key}}" } ``` Used by: Stripe, GitHub, most REST APIs #### API Key Header ```json theme={"dark"} { "X-API-Key": "{{secret:sendgrid_api_key}}" } ``` Used by: SendGrid, Mailgun, custom APIs #### Basic Authentication ```json theme={"dark"} { "Authorization": "Basic {{secret:basic_auth_credentials}}" } ``` **Note:** Secret value should be base64-encoded `username:password` #### Custom Headers ```json theme={"dark"} { "X-Custom-Token": "{{secret:custom_token}}", "X-Webhook-Secret": "{{secret:webhook_secret}}", "X-Client-ID": "{{secret:client_id}}" } ``` *** ## Injecting Secrets in the Request Body KnoxCall can substitute placeholders **inside the request body your client sends** — not just headers. This lets your application reference secrets by name (or by ID) without ever holding the actual values. KnoxCall resolves the placeholders server-side, swaps them for the real decrypted values, and forwards the rewritten body upstream. ### How it works 1. Your client sends a POST/PUT/PATCH with placeholder tokens in the body 2. KnoxCall reads the body, scans for `{{secret:name}}` / `{{secret_id:uuid}}` / `{{var:key}}` 3. KnoxCall checks that each secret named is one the route's own template already sends ([see below](#which-secrets-your-client-may-name)), then resolves it against your tenant's secrets for the route's **active environment** 4. KnoxCall rewrites `Content-Length` and forwards the substituted body to the upstream API 5. Your application never needs to possess the raw credential ### Which secrets your client may name A placeholder your client sends resolves only for a secret **the route already sends**: one that the route's own header or body template references, for the method you are calling. The same rule applies to placeholders your client puts in request headers. If your client names any other secret in your account, KnoxCall refuses the request with `403` and `X-Knox-Error: client_placeholder_not_bound`. The secret it names is never decrypted, and nothing reaches the upstream API. The error message is the same whether or not the named secret exists. If KnoxCall cannot complete that check, for example during a brief database outage, it refuses the request with `503` and `X-Knox-Error: client_placeholder_binding_unavailable` rather than forwarding it. Nothing reaches the upstream API, and the request is safe to retry. This stops a credential that can call one route from pulling a different route's secret into a request it controls. * **Same secret, either form.** If the route's template sends `{{secret_id:550e8400-e29b-41d4-a716-446655440000}}`, your client may send that placeholder or the same secret by name, `{{secret:stripe_prod_key}}`. * **This method's template.** If the route has a different configuration per HTTP method, only the configuration for the method you are calling counts. A secret that only the `DELETE` configuration sends cannot be named in a `POST`. * **Not the mTLS certificate.** A route's client certificate is presented during the TLS handshake and never sent in a request, so a client cannot name it. * **Accounts created before this rule was deployed** are report-only until they are switched over: the placeholder still resolves, and each use is recorded in your audit log as `route.client_placeholder_not_bound`. Accounts created since are refused as described above. The examples below assume the route's own header template already sends the secret they name. ### Placeholder forms accepted in the body | Form | Lookup | Example | Recommended | | - | - | - | - | | `{{secret_id:UUID}}` | By secret **UUID** — stable, rename-safe | `{{secret_id:550e8400-e29b-41d4-a716-446655440000}}` | ✅ **Preferred** | | `{{secret:NAME}}` | By secret **name** — legacy, human-friendly | `{{secret:stripe_prod_key}}` | ⚠️ Works, but breaks on rename | | `{{var:QUERY_PARAM}}` | Value of an incoming query string parameter | `{{var:user_id}}` | — | **Prefer the UUID form (`{{secret_id:...}}`) wherever possible.** A secret's UUID is permanent and never changes, so your code and templates keep working even if someone renames the secret in the dashboard. The name-based form (`{{secret:...}}`) is supported for backwards compatibility and readability, but any rename will silently break every reference to it and the next request will hard-fail with "Missing secret". Also note: `{{secret:UUID}}` is **not** a shortcut for ID-based lookup — it would try to find a secret literally *named* that UUID string and fail. For ID lookups always use the `secret_id:` prefix. You can find a secret's UUID on its detail page in the KnoxCall dashboard, or in the response of the `create_secret` API / onboarding-agent tool call. ### Example — JSON body Your client code sends this to KnoxCall (UUID form recommended; `YOUR_ACCESS_TOKEN` is a `kc_…` OAuth access token — see [Authentication](/api-reference/authentication)): ```http theme={"dark"} POST https://YOUR-SUBDOMAIN.knoxcall.com/v1/charges Content-Type: application/json x-knoxcall-route: ROUTE_SLUG Authorization: Bearer YOUR_ACCESS_TOKEN { "api_key": "{{secret_id:550e8400-e29b-41d4-a716-446655440000}}", "amount": 1000, "currency": "usd" } ``` What the upstream API actually receives: ```http theme={"dark"} POST /v1/charges HTTP/1.1 Host: api.stripe.com { "api_key": "sk_live_EXAMPLE_abc123xyz789...", "amount": 1000, "currency": "usd" } ``` ### Example — form-urlencoded ```http theme={"dark"} POST https://YOUR-SUBDOMAIN.knoxcall.com/send Content-Type: application/x-www-form-urlencoded api_key={{secret_id:550e8400-e29b-41d4-a716-446655440000}}&to=%2B15551234567&body=hello ``` ### Example — plain text / XML ```http theme={"dark"} POST https://YOUR-SUBDOMAIN.knoxcall.com/soap Content-Type: application/xml {{secret_id:550e8400-e29b-41d4-a716-446655440000}} GetInvoice ``` ### Supported Content-Types KnoxCall only rewrites bodies for **text-shaped** Content-Types, so binary uploads stay byte-identical: | Content-Type | Body injection | | - | - | | `application/json` | ✅ | | `application/xml`, `text/xml`, `application/*+xml` | ✅ | | `application/*+json` (e.g. `application/vnd.api+json`) | ✅ | | `application/x-www-form-urlencoded` | ✅ | | `application/javascript`, `application/ecmascript` | ✅ | | `text/*` (plain, html, csv, etc.) | ✅ | | `multipart/form-data` | ❌ passed through unchanged | | `application/octet-stream`, `image/*`, `audio/*`, `video/*`, `application/pdf` | ❌ passed through unchanged | ### Safety rules * **Tenant isolation** — clients can only reference secrets belonging to their own tenant. A UUID guessed from another tenant will hard-fail. * **Route binding** — clients can only reference a secret the route itself already sends. Any other secret is refused with `403 client_placeholder_not_bound` before it is decrypted; accounts created before this rule was deployed are report-only until switched over (see [Which secrets your client may name](#which-secrets-your-client-may-name)). * **Environment-aware** — KnoxCall resolves secrets against the route's active environment. If the secret has no value for that environment, the request fails with a clear error (no silent fallback). * **Size limit** — bodies larger than 1 MiB pass through without substitution to protect the proxy (configurable via `BODY_INJECTION_MAX_BYTES`). * **UTF-8 required** — the body must be valid UTF-8 with no NUL bytes; anything else is treated as binary and passed through untouched. * **Hard-fail on missing secrets** — referencing a secret that doesn't exist (or has no value in the active environment) returns an error instead of sending an empty string upstream. * **Logs are scrubbed** — audit logs store the body **with placeholders intact**, never the resolved secret values. ### Do I still need headers? Yes: configure the route's template first. A placeholder your client sends, in the body or in a header, resolves only for a secret that the route's own `inject_headers_json` or `inject_body_json` template already references for the method you are calling. Any other secret is refused with `403 client_placeholder_not_bound`, so a client placeholder is not a way to pick a secret per call. Use the route's header template for stable auth (e.g. `Authorization: Bearer`). Use a body placeholder where the upstream API expects that same credential inside the payload. ### Common Body Patterns All examples use the **recommended UUID form** — substitute your actual secret IDs. #### Simple API key in body (UUID, rename-safe) ```json theme={"dark"} { "api_key": "{{secret_id:550e8400-e29b-41d4-a716-446655440000}}" } ``` #### Nested objects ```json theme={"dark"} { "credentials": { "username": "admin", "password": "{{secret_id:7a4c1f22-9e8b-4d60-9b32-12e1f4b9c8a1}}" } } ``` #### Legacy name form (works, but breaks if the secret is renamed) ```json theme={"dark"} { "api_key": "{{secret:service_api_key}}" } ``` #### Multiple secrets in one request ```json theme={"dark"} { "payment_key": "{{secret_id:550e8400-e29b-41d4-a716-446655440000}}", "email_key": "{{secret_id:6b1f2f33-c1a4-4f75-a0b7-8d91e2a6f3c2}}", "sms_key": "{{secret_id:a9c82e44-ff3a-4210-9c63-1b8f5cf9e7d1}}" } ``` #### Mixing a secret with a query-string variable ```http theme={"dark"} POST /notify?user_id=42 Content-Type: application/json { "user": "{{var:user_id}}", "token": "{{secret_id:550e8400-e29b-41d4-a716-446655440000}}" } ``` *** ## Multiple Secrets in One Route You can use multiple different secrets in the same route configuration. ### Example: Multi-Service Integration **Route:** `notification-service` **Headers:** ```json theme={"dark"} { "X-Email-Key": "{{secret:sendgrid_api_key}}", "X-SMS-Key": "{{secret:twilio_auth_token}}" } ``` **Body:** ```json theme={"dark"} { "email_config": { "api_key": "{{secret:sendgrid_api_key}}" }, "sms_config": { "auth_token": "{{secret:twilio_auth_token}}", "account_sid": "{{secret:twilio_account_sid}}" }, "push_config": { "server_key": "{{secret:fcm_server_key}}" } } ``` **Result:** All 4 secrets injected in one request! *** ## Environment-Specific Secrets Use different secrets per environment (dev, staging, production). ### Setup **Create environment-specific secrets:** 1. **Production:** ```text theme={"dark"} Name: stripe_prod_key Value: sk_live_abc123... ``` 2. **Staging:** ```text theme={"dark"} Name: stripe_staging_key Value: sk_test_xyz789... ``` 3. **Development:** ```text theme={"dark"} Name: stripe_dev_key Value: sk_test_dev123... ``` ### Configure Route Per Environment **Base environment (production):** ```json theme={"dark"} { "Authorization": "Bearer {{secret:stripe_prod_key}}" } ``` **Override for staging:** ```json theme={"dark"} { "Authorization": "Bearer {{secret:stripe_staging_key}}" } ``` **Override for development:** ```json theme={"dark"} { "Authorization": "Bearer {{secret:stripe_dev_key}}" } ``` ### How to Set Up 1. Go to route → **Environment** tab 2. Select "staging" 3. Edit **Inject Headers**: ```json theme={"dark"} { "Authorization": "Bearer {{secret:stripe_staging_key}}" } ``` 4. Save 5. Repeat for "development" **Result:** * Production uses: `stripe_prod_key` * Staging uses: `stripe_staging_key` * Development uses: `stripe_dev_key` All with the same route! *** ## Method-Specific Secrets Different secrets for different HTTP methods on the same route. ### Example Scenario Route handles both GET (read) and POST (write) to same API: * GET needs read-only key * POST needs full-access key ### Setup **GET method config:** ```json theme={"dark"} { "method": "GET", "inject_headers_json": { "Authorization": "Bearer {{secret:api_read_only_key}}" } } ``` **POST method config:** ```json theme={"dark"} { "method": "POST", "inject_headers_json": { "Authorization": "Bearer {{secret:api_full_access_key}}" } } ``` **Result:** * GET requests use read-only key * POST requests use full-access key *** ## Secret Resolution Order When multiple environments and methods are involved, KnoxCall resolves secrets in this order: 1. **Method-specific** environment override 2. **Environment override** (if method not specified) 3. **Base route** configuration ### Example ```text theme={"dark"} Request: POST to route in "staging" environment Lookup order: 1. Check: staging → POST method config → secrets 2. If not found: staging → default config → secrets 3. If not found: base (production) → POST method config → secrets 4. If not found: base (production) → default config → secrets ``` *** ## Template Syntax Rules ### ✅ Correct Syntax ```json theme={"dark"} { "Authorization": "Bearer {{secret:stripe_key}}" } ``` **Rules:** * Double curly braces: `{{` and `}}` * Prefix: `secret:` * No spaces inside braces * Lowercase with underscores for secret names ### ❌ Common Mistakes ```json theme={"dark"} // ❌ Single braces { "Authorization": "Bearer {secret:stripe_key}" } // ❌ No prefix { "Authorization": "Bearer {{stripe_key}}" } // ❌ Wrong prefix { "Authorization": "Bearer {{env:stripe_key}}" } // ❌ Spaces inside { "Authorization": "Bearer {{ secret:stripe_key }}" } // ❌ Hyphens in name { "Authorization": "Bearer {{secret:stripe-key}}" } ``` *** ## Real-World Examples ### Example 1: Stripe Payments **Secret:** ```text theme={"dark"} Name: stripe_prod_key Value: sk_live_EXAMPLE_abc123xyz789... ``` **Route config:** ```json theme={"dark"} { "inject_headers_json": { "Authorization": "Bearer {{secret:stripe_prod_key}}", "Content-Type": "application/json" } } ``` **Actual request sent to Stripe:** ```http theme={"dark"} POST /v1/charges HTTP/1.1 Host: api.stripe.com Authorization: Bearer sk_live_EXAMPLE_abc123xyz789... Content-Type: application/json {"amount": 1000, "currency": "usd"} ``` ### Example 2: SendGrid Email **Secret:** ```text theme={"dark"} Name: sendgrid_api_key Value: SG.abc123xyz789... ``` **Route config:** ```json theme={"dark"} { "inject_headers_json": { "Authorization": "Bearer {{secret:sendgrid_api_key}}", "Content-Type": "application/json" }, "inject_body_json": { "personalizations": [{ "to": [{"email": "user@example.com"}] }], "from": {"email": "noreply@acme.com"}, "subject": "Welcome!", "content": [{ "type": "text/html", "value": "

Welcome!

" }] } } ``` ### Example 3: Database Access **Secret:** ```text theme={"dark"} Name: postgres_connection_url Value: postgres://user:pass@host:5432/db ``` **Route config:** ```json theme={"dark"} { "inject_body_json": { "connection_string": "{{secret:postgres_connection_url}}", "query": "SELECT * FROM users WHERE id = $1", "params": [123] } } ``` ### Example 4: PrintNode Printing **Secret:** ```text theme={"dark"} Name: printnode_api_key Value: abc123xyz789... ``` **Route config:** ```json theme={"dark"} { "inject_headers_json": { "Authorization": "Basic {{secret:printnode_api_key}}" }, "inject_body_json": { "printerId": 123, "title": "Receipt", "contentType": "pdf_uri", "content": "https://example.com/receipt.pdf", "source": "KnoxCall POS" } } ``` ### Example 5: Multi-Service Orchestration **Secrets:** ```text theme={"dark"} stripe_key sendgrid_key twilio_key ``` **Route config:** ```json theme={"dark"} { "inject_headers_json": { "X-Service": "orchestrator" }, "inject_body_json": { "actions": [ { "service": "payment", "api_key": "{{secret:stripe_key}}", "action": "charge" }, { "service": "email", "api_key": "{{secret:sendgrid_key}}", "action": "send_receipt" }, { "service": "sms", "api_key": "{{secret:twilio_key}}", "action": "send_notification" } ] } } ``` *** ## Combining Secrets with Other Templates You can combine secrets with the other supported template forms in a single body or header set: ```json theme={"dark"} { "Authorization": "Bearer {{secret_id:550e8400-e29b-41d4-a716-446655440000}}", "X-Legacy-Key": "{{secret:api_key}}", "X-User-ID": "{{var:user_id}}" } ``` **Supported placeholder forms:** * `{{secret_id:UUID}}` - Inject a secret by its **UUID** (preferred — rename-safe) * `{{secret:name}}` - Inject a secret by its **name** (legacy — breaks on rename) * `{{var:QUERY_PARAM}}` - Substitute the value of an incoming query-string parameter These are the only placeholder forms the template engine resolves. Tokens such as `{{header:name}}`, `{{env:name}}`, or `{{uuid}}` are **not** supported and are passed through to the upstream unchanged. *** ## Security Considerations ### What Gets Logged **In KnoxCall logs:** * Template syntax shown: `{{secret:stripe_key}}` * Actual value: **Never logged** **Example log entry:** ```json theme={"dark"} { "request_headers": { "Authorization": "Bearer {{secret:stripe_key}}" } } ``` Plaintext value is not exposed in logs for security. ### Secret Transmission **Flow:** ```text theme={"dark"} 1. Client makes request to KnoxCall ↓ 2. KnoxCall receives request ↓ 3. Loads route configuration ↓ 4. Sees {{secret:stripe_key}} template ↓ 5. Decrypts secret SERVER-SIDE ↓ 6. Replaces template with plaintext ↓ 7. Forwards request to backend ↓ 8. Backend receives real API key ↓ 9. Client NEVER sees the plaintext! ``` ### Best Practices ✅ **Do:** * Use secrets for all sensitive values * Separate secrets per environment * Rotate secrets regularly * Use descriptive secret names * Document what each secret is for ❌ **Don't:** * Hardcode API keys in route configs * Use production secrets in development * Share secrets via email or chat * Commit secrets to git * Use generic names like "key" or "secret" *** ## Troubleshooting ### Secret Not Replacing **Symptom:** Backend receives `{{secret:stripe_key}}` literally. **Causes:** 1. Syntax error in template 2. Secret doesn't exist 3. Secret name typo **Debug steps:** 1. Check template syntax: `{{secret:name}}` 2. Verify secret exists: Resources → Secrets 3. Check spelling matches exactly (case-sensitive) 4. Look at logs for error messages ### Wrong Value Injected **Symptom:** Wrong API key being used. **Causes:** 1. Using wrong environment 2. Wrong secret name 3. Secret not configured for environment **Fix:** 1. Check which environment request is using 2. Verify environment override has correct secret 3. Check secret name spelling ### Secret Not Found Error **Error:** `Secret 'api_key' not found` **Solution:** 1. Go to Resources → Secrets 2. Check secret exists 3. Verify name matches template exactly 4. Check no typos: `api_key` vs `api_key_` ### Value Not Decrypting **Symptom:** Encrypted value sent instead of plaintext. **Cause:** KnoxCall internal error (very rare). **Solution:** 1. Check KnoxCall status 2. Try creating new secret version 3. Contact support if persists *** ## Testing Secret Injection ### Test Endpoint Use a test endpoint to verify secret injection: 1. **Create test route:** * Target: `https://httpbin.org/anything` * Method: POST 2. **Add secret in header:** ```json theme={"dark"} { "X-Test-Secret": "{{secret:test_api_key}}" } ``` 3. **Make request:** ```bash theme={"dark"} # $TOKEN is a kc_… OAuth access token — see /api-reference/authentication curl -X POST https://YOUR-SUBDOMAIN.knoxcall.com/anything \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: test-route" ``` 4. **Check response:** ```json theme={"dark"} { "headers": { "X-Test-Secret": "your-actual-secret-value-here" } } ``` ✅ If you see actual value, injection works! *** ## Quick Reference | Use Case | Template Syntax | Example | | - | - | - | | **Header** | `{{secret:name}}` | `"Authorization": "Bearer {{secret:api_key}}"` | | **Body** | `{{secret:name}}` | `"api_key": "{{secret:api_key}}"` | | **Multiple** | Multiple templates | `{{secret:key1}}` and `{{secret:key2}}` | | **Environment** | Different secret per env | Production: `{{secret:prod_key}}` | *** ## Next Steps How to create and manage secrets Complete secrets guide Environment-specific secrets Auto-refreshing OAuth2 tokens *** **Pro Tip:** Always test secret injection with a test endpoint (like httpbin.org) before using in production. This lets you verify the actual decrypted value is correct. # Tenant KMS key rotation Source: https://docs.knoxcall.com/essentials/tenant-kms/key-rotation Rotate your tenant master key to re-wrap all new data under a fresh KMS-backed version. Understand the lifecycle of active, retired, and revoked versions. # Tenant KMS Key Rotation Rotation creates a new tenant master key version wrapped under your KMS key. New writes immediately use the new version; existing ciphertext stays readable because the retired version remains available for unwrap. A background rewrap job migrates existing data to the new version automatically. ## What happens during rotation 1. A new 32-byte master key is generated and wrapped using your KMS key → new `active` version 2. The previous `active` version is moved to `retired` 3. New encrypts (new secrets, new CA key versions, new DB passwords) use the new active version immediately 4. Existing ciphertext wrapped by the retired version continues to unwrap successfully 5. A background rewrap job is enqueued to re-encrypt all existing tenant data under the new active version ## When to rotate * **Suspected compromise** of the old master key version * **Scheduled compliance rotation** (annual, quarterly — check your policy) * **Post-breach seal/unseal cycle** — rotate after any event where you were unsure of KMS security * **KMS key rotation** on the cloud provider side — rotate the KnoxCall master key after rotating the underlying KMS key so new wraps use the new KMS key version **Azure Key Vault: rotating the key in Azure does not, by itself, change what KnoxCall uses.** Azure resolves the key version from the `kms_key_ref` URL, not from the ciphertext — so a version-pinned reference (the only kind KnoxCall accepts) keeps wrapping and unwrapping against the version it names, even after you create a new one. This is deliberate: prior key versions stay enabled in Azure, so your existing data remains readable. To actually move to a new Azure key version, update `kms_key_ref` to the new versioned URL (Settings → Security → Tenant Master Key, or `PUT /admin/tenant-kms`) and then rotate the KnoxCall master key. Do **not** disable or destroy the old Azure key version until the rewrap job has finished — until then, retired master key versions still need it. AWS KMS and Google Cloud KMS behave differently and need no such step: their ciphertext records the key version it was produced under, so unwrapping keeps working across a provider-side rotation automatically. ## How to rotate ### Via admin UI 1. Go to **Settings → Security → Tenant Master Key → Customer KMS** 2. Click **Rotate key** 3. Confirm the dialog ### Via API Rotation requires a **recent step-up verification within a 5-minute window**. Complete a fresh second-factor step-up with `"action_scope": "tenant.kms"` on the same admin session immediately before calling rotate: a passkey via `POST /auth/passkey/verify-step-up`, an authenticator-app code via `POST /auth/2fa/verify-step-up`, or a security key (or passkey) via `POST /auth/2fa/webauthn/step-up-options` then `POST /auth/2fa/webauthn/verify-step-up`. An emailed code does not satisfy this gate — the gate checks the server-side verification record, so no separate step-up header is sent on the rotate request itself. A verification made for another action does not satisfy it, and a missing one answers `403` with `"requires_step_up": true`, `"action_scope": "tenant.kms"` and `"allowed_methods": ["passkey", "2fa"]`. ```bash theme={"dark"} curl -X POST https://admin.knoxcall.com/admin/tenant-kms/rotate \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "X-Tenant-ID: $KC_TENANT_ID" ``` Response: ```json theme={"dark"} { "rotated": true, "new_version": 2, "rewrap_lease_id": 42 } ``` `rewrap_lease_id` is the identifier for the background rewrap job described below. It is `null` if the rewrap lease failed to issue — the rotation itself still succeeded in that case; contact support to trigger the rewrap manually. Rotation is **non-reversible**. Once the old version is retired, you cannot make it active again. You can still decrypt existing data, but new writes will use the new version. If you revoke the old version later, anything written under it becomes permanently unreadable. Cannot be called while the tenant is sealed — unseal first. ## Background rewrap After rotation completes, KnoxCall immediately enqueues a `tenant_rewrap` background job. This job re-encrypts all existing tenant data under the new active master key version: * Processes **100 rows per batch** (interleaved: 50 from `secret_environment_configs`, 50 from `integration_configs`) * Batches run every **5 seconds** * The `target_version` is pinned at job creation time — even if another rotation occurs mid-flight, all rows processed by this job land on the same version (prevents split-version state across a rotation boundary) * Rows already on the target version are skipped and counted as complete * The lease expires after **7 days** — if a persistent KMS error prevents rewrap from completing in that window, contact support You can track rewrap progress via Automation → Agents in the admin UI. The old retired version remains available for decryption throughout the rewrap and beyond, until you explicitly revoke it. ## Audit trail Every rotation is logged in the [critical audit chain](/monitoring/audit-logs#critical-audit-chain): | Audit action | Meaning | | - | - | | `byok.kms.rotate` | Master key rotation initiated | The log entry includes `new_version`, `wrap_method`, `rewrap_lease_id`, and the IP that triggered it. ## Key version lifecycle ```text theme={"dark"} ACTIVE (v2) ← current wrapping RETIRED (v1) ← still decryptable, no new wraps ↓ (if explicitly revoked) REVOKED (v1) ← cryptographic erasure — data is permanently unreadable ``` Revoking a retired version is a destructive, permanent action. Use it only when you need to guarantee that data written under that version can never be recovered — for example, data destruction per GDPR right-to-erasure. ## Checking key versions ```bash theme={"dark"} curl https://admin.knoxcall.com/admin/tenant-kms \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "X-Tenant-ID: $KC_TENANT_ID" ``` The response `keys` array contains all versions: ```json theme={"dark"} { "config": { ... }, "keys": [ { "id": "d290f1ee-6c54-4b01-90e6-d701748f0851", "version": 2, "wrap_method": "customer_kms", "status": "active", "created_at": "2026-05-25T10:00:00Z", "retired_at": null }, { "id": "a7c1d8f3-2e4b-4c6a-8d9e-1f3b5a7c9d2e", "version": 1, "wrap_method": "customer_kms", "status": "retired", "created_at": "2026-01-01T00:00:00Z", "retired_at": "2026-05-25T10:00:00Z" } ] } ``` What happens if KMS becomes unreachable after rotation Version lifecycle and cryptographic erasure background Rotate endpoint reference View the rotation audit trail # Tenant KMS (BYOK) overview Source: https://docs.knoxcall.com/essentials/tenant-kms/overview Bring your own KMS key from AWS, GCP, or Azure. KnoxCall wraps all tenant data under your key — you hold the kill switch. # Tenant KMS (BYOK) Overview By default every KnoxCall tenant's master key is wrapped by the platform's own `MASTER_KEY_B64`. That's strong isolation, but the wrapping key lives on KnoxCall's infrastructure. **Tenant KMS** lets you go a step further: the wrapping key lives in **your** cloud account. KnoxCall is granted a narrow IAM permission to call `Encrypt` / `Decrypt` against that one key — nothing else. Revoke the grant and KnoxCall can no longer unwrap on the next cache miss. The tenant enters the [sealed state](/essentials/tenant-kms/sealed-state) on that next miss — up to one hour later, since the unwrapped master key is cached for one hour. ## How it differs from Operator KMS There are three wrapping options: | Wrap method | Who holds the wrapping key | Kill-switch location | | - | - | - | | `global` | KnoxCall (env var) | None — contact support | | `operator_kms` | KnoxCall-managed KMS key | None — contact support | | `customer_kms` (**BYOK**) | **Your** AWS / GCP / Azure account | **Your** IAM console | BYOK is Customer KMS. The Tenant KMS dashboard manages it. ## When to use it * **Regulatory requirement** — FedRAMP, HIPAA, HITRUST, EU data residency frameworks that require customer-controlled key material * **Kill-switch requirement** — need to instantly revoke KnoxCall's access to tenant data without contacting support * **Audit requirement** — need your own KMS audit log to record every unwrap call KnoxCall makes BYOK tenants have a **5-minute agent session window**, not the standard 1 hour. Agent sessions renew more frequently so that a revoked KMS grant caps in-flight plaintext exposure at roughly 5 minutes. Plan for this if you run long-lived self-hosted proxy deployments. (This is the agent *session* TTL — the in-process master-key cache TTL is 1 hour for all tenants.) ## Architecture KMS is not in the path of every encrypt/decrypt — only on a cache miss: ```text theme={"dark"} Incoming request ↓ KnoxCall proxy needs tenant master key ↓ Cache HIT → use cached key (valid for 1 hour) Cache MISS → call customer KMS via IAM → unwrap master key → cache for 1 hour ↓ Encrypt / decrypt with master key (local, fast) ``` This means one KMS round-trip per 1-hour window, not per request. ### Key version states | State | Meaning | | - | - | | `active` | Current version. New wraps use this. One per tenant at a time. | | `retired` | Older version — still unwraps legacy ciphertext, no new wraps. | | `revoked` | Cryptographic erasure. Data wrapped by this version is permanently unreadable. | ## Prerequisites * **Owner** or **Admin** role on the tenant * Provider IAM set up before onboarding — KnoxCall validates the grant at onboarding time Static long-lived credentials (AWS access keys, GCP service account JSON key files) are **rejected at the API level**. AWS uses cross-account STS role assumption; GCP uses service account impersonation; Azure uses a client secret env var or Managed Identity. No credential value is ever stored in the database. Onboarding (`POST /admin/tenant-kms`) does **not** require a step-up verification. Step-up is only required for sensitive post-onboard operations: unseal (2-minute window), rotate (5-minute window) and revert (2-minute window). Each needs a second-factor verification made with `"action_scope": "tenant.kms"`; an emailed code does not count. ## Quick start Before saving, KnoxCall runs a **probe**: wrap then unwrap 32 test bytes against your KMS key. If the IAM grant isn't working, the request fails immediately and nothing is written to the database. Calling this endpoint when a KMS config already exists **replaces it** (UPSERT) — the probe runs first; if it fails, the existing config is unchanged. ```bash theme={"dark"} curl -X POST https://admin.knoxcall.com/admin/tenant-kms \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "Content-Type: application/json" \ -d '{ "provider": "aws", "kms_key_ref": "arn:aws:kms:us-east-1:111122223333:key/abcd-1234-...", "config": { "region": "us-east-1", "role_arn": "arn:aws:iam::111122223333:role/KnoxCallKMSAccess", "external_id": "your-chosen-external-id" } }' ``` For the full setup flow for each provider, see the pages below. ## Cache architecture The master key is cached **in-process on the KnoxCall control plane** — not per-agent and not in Redis. Cache key: `:active`, TTL 1 hour (`DEFAULT_CACHE_TTL_MS`) for all tenants. Concurrent cache misses for the same tenant are deduplicated: a single KMS call is made and the result is shared to all waiters. This means KnoxCall makes roughly one KMS call per 1-hour window per tenant, regardless of agent count. ## Sealed state If KnoxCall can't reach your KMS (IAM revoked, network partition, key disabled), the tenant enters the **sealed state**. New agent sessions fail with `503 Service Unavailable`. In-flight agents with a valid session continue operating until their session expires (at most 5 minutes for BYOK tenants). See [Sealed state and unseal](/essentials/tenant-kms/sealed-state) for recovery steps. Cross-account IAM role + STS assumption Service account impersonation — no JSON key files Managed Identity or Federated Credentials What happens when KMS is unreachable, and how to recover Rotate your tenant master key under a new KMS-backed version Background on global / operator\_kms / customer\_kms wrapping options # Sealed state and unseal Source: https://docs.knoxcall.com/essentials/tenant-kms/sealed-state What happens when KnoxCall can't reach your KMS, and how to restore service. The sealed state is reversible — revocation is not. # Sealed State and Unseal When KnoxCall can't unwrap the tenant master key from your KMS, the tenant enters the **sealed state**. New agent sessions cannot be issued until access is restored. This is a safety mechanism, not a failure. It guarantees that your kill switch works: if you revoke KnoxCall's IAM access, the tenant seals within one cache TTL. ## What triggers sealing * IAM permission revoked (your kill switch in action) * KMS key disabled or scheduled for deletion * Network partition between KnoxCall and your KMS endpoint * KMS service outage in your cloud provider's region * KMS key version revoked (this triggers **permanent** sealing — see below) ## What happens when sealed Sealing is enforced at the **agent session layer**, not at individual proxy requests: * **Session endpoint**: `POST /agent/v1/session` (the endpoint agents call to obtain a session bundle) returns `503 Service Unavailable` with `{ "error": { "type": "tenant_sealed", "message": "..." } }` * **In-flight agents**: agents that already hold a valid session continue to operate normally until their session expires — at most **5 minutes** for BYOK tenants * **After session expiry**: agents must renew; renewal fails with 503 until the tenant is unsealed * **Admin UI**: a sealed banner appears on the Customer KMS page (Settings → Security → Tenant Master Key → Customer KMS) with the last KMS error message * **KMS config row**: `sealed_since` timestamp set; visible in `GET /admin/tenant-kms`. No audit action is written for the seal event itself — check `last_verify_error` for the reason There is no per-request `423 Locked` response at the proxy layer. The sealed check happens once per session renewal. An agent mid-request is unaffected until its current session TTL runs out. ## Sealed vs revoked — a critical distinction | | Sealed | Revoked | | - | - | - | | Trigger | KMS unreachable or IAM revoked | Tenant master key version explicitly revoked in KnoxCall | | Reversible? | ✅ Yes — restore KMS access | ❌ No — permanent cryptographic erasure | | Data state | Intact at rest, temporarily inaccessible | Data wrapped by that version is permanently unreadable | | Recovery | Restore IAM + unseal | None | Revoking the master key in KnoxCall is different from revoking IAM access. If you want a recoverable kill switch, revoke the IAM grant. If you want data destruction, revoke the master key in KnoxCall. ## Monitoring for seal events **Seal state**: Sealing records `sealed_since` and `last_verify_error` on the tenant's KMS config row. There is no `byok.kms.seal` audit action — the seal itself is not logged in the audit chain. Check `GET /admin/tenant-kms` and look for a non-null `sealed_since` field. **Unseal audit log**: When the tenant unseals (manually or automatically), a `byok.kms.unseal` critical audit entry is written. Filter by that action in Monitoring → Audit Logs to see the history of seal/unseal cycles. **Alerts**: There is no dedicated seal webhook event, so monitor by polling `GET /admin/tenant-kms`: if `sealed_since` becomes non-null, page on-call immediately — new agent sessions will fail within the next 5 minutes. ## Unsealing ### Automatic unseal Once you restore KMS access (re-grant the IAM permission, re-enable the key, resolve the network partition), the tenant clears its sealed flag **lazily, on the next successful unwrap** — no manual action required. There is no background poller: the sealed flag is cleared the next time KnoxCall has to unwrap that tenant's master key (the next cache miss), which is driven by agent traffic rather than a fixed timer. If no traffic arrives, use the manual unseal endpoint below to force it immediately. The `byok.kms.unseal` action is logged when the tenant comes back up. ### Manual unseal via API If you need to force an immediate unseal attempt (rather than waiting for the next unwrap), call the unseal endpoint. This is an admin (control-plane) endpoint: it's served on the admin host, authenticated with your session JWT plus the `X-Tenant-ID` header, and requires the owner/admin role. It also requires a **recent step-up verification within a 2-minute window**: ```bash theme={"dark"} curl -X POST https://admin.knoxcall.com/admin/tenant-kms/unseal \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "X-Tenant-ID: $KC_TENANT_ID" ``` The step-up requirement is enforced server-side against your account, not via a header on this request. Within the 2 minutes before calling unseal, complete a second-factor step-up on the same session with `"action_scope": "tenant.kms"`: `POST /auth/2fa/verify-step-up` with `{"totp_code": "", "action_scope": "tenant.kms"}`, or a WebAuthn step-up with the same scope (`POST /auth/passkey/verify-step-up` for a passkey, or `POST /auth/2fa/webauthn/step-up-options` then `POST /auth/2fa/webauthn/verify-step-up` for a security key). An emailed code does not count, and a verification made for another action, such as one an unrelated Dashboard prompt raised, carries that action's scope and is refused here. The window is intentionally tight. The Dashboard's own **Unseal** button raises the right verification for you. If no matching verification is on file, the endpoint returns `403` with `"requires_step_up": true`, `"action_scope": "tenant.kms"` and `"allowed_methods": ["passkey", "2fa"]`. This endpoint is not idempotent: calling it when the tenant is already unsealed returns `409 not_sealed`. Response on success: ```json theme={"dark"} { "unsealed": true } ``` If the KMS is still unreachable or access is still denied, the response is `502 unseal_failed` with a `reason` field: ```json theme={"dark"} { "error": "unseal_failed", "reason": "access_denied", "message": "..." } ``` `reason` is either `"access_denied"` (IAM grant still missing or invalid) or `"unreachable"` (network / KMS service issue). ### Unseal via admin UI 1. Go to **Settings → Security → Tenant Master Key → Customer KMS** 2. The sealed banner shows the last KMS error 3. After restoring KMS access, click **Unseal** ## Troubleshooting **Unseal stuck, KMS access looks correct:** * Verify the IAM role still exists and the trust policy is intact: `aws iam get-role --role-name KnoxCallKMSAccess` * For GCP: verify KnoxCall's principal still has `roles/iam.serviceAccountTokenCreator` on your service account — run `gcloud iam service-accounts get-iam-policy knoxcall-kms-sa@YOUR_PROJECT.iam.gserviceaccount.com` * For Azure: check the `Key Vault Crypto User` role assignment still exists on the vault **`502 unseal_failed` with `reason: "access_denied"`:** * The IAM/RBAC grant was removed or expired — restore it and retry immediately * For Azure: confirm the client secret env var is still set on the KnoxCall server process and hasn't rotated **`409 not_sealed` on the unseal endpoint:** The tenant is not currently sealed. No action needed — the tenant is operating normally. **Tenant unseals but reseals again after a few minutes:** * The IAM grant may have been partially restored — check that both wrap and unwrap operations are permitted (AWS: `kms:Encrypt` and `kms:Decrypt`; Azure: `wrapKey` and `unwrapKey`) * KMS key rotation in your cloud provider may have invalidated the key reference stored in KnoxCall — check `GET /admin/tenant-kms` for `last_verify_error` **KMS access cannot be restored (key deleted, account closed, provider off-boarded):** As a last resort, [revert to a KnoxCall-managed key](/api-reference/tenant-kms/revert) (**Settings → Security → Customer KMS → Revert to operator key**). If your KMS is still reachable the revert is lossless — everything is re-encrypted first. If it is not, you must explicitly acknowledge that secrets written while BYOK was active become unreadable and re-enter their values afterwards. Rotate your tenant master key to a new version Unseal and rotate endpoint reference Re-check your IAM setup Find seal and unseal events in the audit trail # Tenant KMS: AWS KMS setup Source: https://docs.knoxcall.com/essentials/tenant-kms/setup-aws Configure AWS KMS as your tenant's wrapping key provider using cross-account IAM role assumption. No static credentials accepted. This guide walks you through creating an AWS KMS key, granting KnoxCall cross-account access via an IAM role, and onboarding it as your tenant's wrapping key. ## Prerequisites * AWS account with permission to create KMS keys and IAM roles * KnoxCall **Owner** or **Admin** role Static credentials (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`) are **rejected at the API level** — not just discouraged. Cross-account role assumption via `role_arn` + `external_id` is the only accepted authentication method. ## Step 1: Create a symmetric KMS key In your AWS account, create a new symmetric KMS key: ```bash theme={"dark"} aws kms create-key \ --description "KnoxCall tenant master key wrapping" \ --key-usage ENCRYPT_DECRYPT \ --key-spec SYMMETRIC_DEFAULT \ --region us-east-1 # Note the KeyId and Arn in the response ``` Optionally add an alias: ```bash theme={"dark"} aws kms create-alias \ --alias-name alias/knoxcall-tenant-wrapping \ --target-key-id ``` ## Step 2: Create a cross-account IAM role KnoxCall assumes a role in your account using STS. Create an IAM role with the following trust policy — replace `` with KnoxCall's AWS account ID, shown in the BYOK setup wizard (**Settings → Security → Tenant Master Key → Customer KMS → Set up BYOK**, AWS step). The wizard also renders this exact trust policy ready to paste, with the account ID and your external ID already substituted. If you prefer the API, the same value is available at: ```bash theme={"dark"} curl https://admin.knoxcall.com/admin/tenant-kms/provider-identity \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "X-Tenant-ID: $KC_TENANT_ID" # → { "aws": { "configured": true, "account_id": "...", "principal_arn": "..." }, "gcp": { ... } } ``` ```json theme={"dark"} { "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam:::root" }, "Action": "sts:AssumeRole", "Condition": { "StringEquals": { "sts:ExternalId": "" } } }] } ``` **Do not choose this value yourself.** KnoxCall issues it, and it is shown in **Settings → Security → Tenant Master Key** (format `knoxcall::`). Copy it verbatim into the condition above. This is what makes the external ID a confused-deputy defence. The `Principal` above is KnoxCall's whole AWS account, so the external ID is the only thing distinguishing your role from any other customer's. If customers picked their own, one tenant could submit another tenant's role ARN together with that tenant's external ID — a value that lives in Terraform state, CI logs and support tickets, not in a vault — and have KnoxCall assume it on their behalf. Onboarding rejects any value other than the one we issued. Attach this inline permission policy to the role, scoped to only the one KMS key: ```json theme={"dark"} { "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Action": ["kms:Encrypt", "kms:Decrypt"], "Resource": "" }] } ``` Note the role's ARN — you'll need it in the next step. ## Step 3: Onboard via admin UI 1. Go to **Settings → Security**, find the **Tenant Master Key** card, click **Customer KMS**, then **Set up BYOK** 2. Select **AWS KMS** and click **Next** 3. Fill in: * **KMS key ARN** — the full `arn:aws:kms:...` ARN (or alias ARN) * **Region** — the AWS region where your KMS key lives * **Cross-account role ARN** — the role you created in Step 2 * **External ID** — the UUID from Step 2 4. Click **Next** to review, then **Confirm + activate** KnoxCall performs a probe encrypt + decrypt round-trip before committing. If verification fails, the change is not applied. ## Step 4: Onboard via API No step-up verification is required for onboarding. KnoxCall runs a probe (wrap + unwrap on 32 test bytes) before writing anything — if the IAM grant isn't working, the request fails immediately with `400` and a plain message like `KMS access denied. Check that the IAM/RBAC grant gives KnoxCall both Encrypt and Decrypt on this key.` (or `KMS probe failed.` for other probe errors). If KMS itself is unreachable, you get `502` with `KMS provider unreachable during probe.` instead. ```bash theme={"dark"} curl -X POST https://admin.knoxcall.com/admin/tenant-kms \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "X-Tenant-ID: $KC_TENANT_ID" \ -H "Content-Type: application/json" \ -d '{ "provider": "aws", "kms_key_ref": "arn:aws:kms:us-east-1:111122223333:key/abcd-1234-5678-efgh", "config": { "region": "us-east-1", "role_arn": "arn:aws:iam::111122223333:role/KnoxCallKMSAccess", "external_id": "550e8400-e29b-41d4-a716-446655440000" } }' ``` Note: `provider` is `"aws"` (not `"aws_kms"`). A successful response: ```json theme={"dark"} { "ok": true, "provider": "aws", "kms_key_ref": "arn:aws:kms:us-east-1:111122223333:key/abcd-1234-5678-efgh", "new_key_version": 1, "new_key_id": "d290f1ee-6c54-4b01-90e6-d701748f0851", "rewrap_status": "enqueued", "note": "A background rewrap pass will re-encrypt existing tenant data under the new key. Existing in-flight agent sessions remain valid for up to 5 minutes (BYOK session TTL)." } ``` `rewrap_status` is always `"enqueued"` — KnoxCall unconditionally issues a background rewrap lease after onboarding. ## Step 5: Verify the configuration ```bash theme={"dark"} curl https://admin.knoxcall.com/admin/tenant-kms \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "X-Tenant-ID: $KC_TENANT_ID" ``` Confirm `config.sealed_since` is `null` and the `keys` array contains one active version. ## Session duration impact Once BYOK is active, all agent sessions for this tenant have a **5-minute TTL** instead of the default 1 hour. Self-hosted proxy agents will renew sessions more frequently — this is intentional to bound plaintext exposure if you later revoke KMS access. Ensure your agents are not configured with session renewal intervals longer than 4 minutes. ## Revoking access (the kill switch) To lock KnoxCall out of your tenant data: 1. Remove (or deny) the `kms:Decrypt` permission from the IAM role — or detach the inline policy entirely. 2. On the next unwrap cache miss — within one cache TTL (1 hour) — KnoxCall will fail to unwrap and the tenant will enter the [sealed state](/essentials/tenant-kms/sealed-state). New agent sessions stop being issued sooner: because BYOK caps session TTL at 5 minutes, no fresh plaintext exposure can be granted beyond that window. This is reversible — restore the IAM permission and KnoxCall will unseal automatically on the next successful unwrap. To **permanently destroy** the data, revoke the tenant master key in KnoxCall itself (cryptographic erasure). The KMS key in your account becomes irrelevant — the wrapped key material is gone. ## Troubleshooting **`AccessDenied` during onboarding verify:** * Check the trust policy includes the correct KnoxCall AWS account ID (shown in the BYOK setup wizard's AWS step) * Verify `sts:ExternalId` in the trust policy matches what you entered exactly * Confirm the inline policy ARN matches the KMS key ARN (no typos) **`NoCredentialProviders` error:** * The KnoxCall principal ARN must appear in the trust policy's `Principal.AWS` field * Ensure you saved the role after adding the trust policy **Role ARN mismatch:** * Copy the role ARN directly from the IAM console — avoid hand-typing the account ID **Verification times out:** * Check KMS key is not in a pending-deletion state: `aws kms describe-key --key-id ` * Verify the KMS key is in the correct region Service account impersonation for GCP What to do when KMS becomes unreachable Rotate to a new master key version Full endpoint reference # Tenant KMS: Azure Key Vault setup Source: https://docs.knoxcall.com/essentials/tenant-kms/setup-azure Configure Azure Key Vault as your tenant wrapping key provider using a client secret env var or Managed Identity. Client secret values are never stored in the database. This guide walks you through creating an Azure Key Vault and key, granting KnoxCall access, and onboarding it as your tenant's wrapping key. Two authentication paths are available: | Option | When to use | | - | - | | **App Registration + client secret env var** | Cloud-hosted KnoxCall (recommended for most tenants) | | **Managed Identity** | Self-hosted KnoxCall on an Azure VM, App Service, or AKS | ## Prerequisites * Azure subscription with permission to create Key Vaults, App registrations, and RBAC role assignments * KnoxCall **Owner** or **Admin** role The `client_secret_env_var` field accepts the **name** of an environment variable set on the KnoxCall server — the actual secret value is read at runtime and is **never stored in the database**. This is enforced server-side. Passing a raw client secret value as the field value is rejected. *** ## Step 1: Create an Azure Key Vault and key ```bash theme={"dark"} az keyvault create \ --name my-knoxcall-kv \ --resource-group my-rg \ --location eastus \ --sku premium \ --enable-rbac-authorization true az keyvault key create \ --vault-name my-knoxcall-kv \ --name tenant-wrapping-key \ --kty RSA \ --size 2048 \ --ops wrapKey unwrapKey ``` Note the vault key URL — you'll use it as `kms_key_ref`. **It must include the key version** (the trailing identifier). `az keyvault key create` prints it as `kid`: ```text theme={"dark"} https://my-knoxcall-kv.vault.azure.net/keys/tenant-wrapping-key/9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c ``` A **versionless** URL (`.../keys/tenant-wrapping-key`, with no trailing version) is rejected. Azure resolves the key version from the URL rather than from the ciphertext, so a versionless reference always unwraps against whatever version the vault currently considers latest. The first time you rotated the key in Azure, every master key wrapped under the old version would become permanently unreadable — including retired versions, and therefore all data still encrypted under them. Pinning the version is safe under rotation: Azure keeps prior key versions enabled, so the pinned version keeps unwrapping. Moving to a new version is a deliberate step — see [Key rotation](/essentials/tenant-kms/key-rotation). Use the RBAC authorization model (`--enable-rbac-authorization true`), not access policies. This is required for the Key Vault Crypto User role to work. *** ## Option A: App Registration + client secret env var This option works for any deployment. The App Registration acts as KnoxCall's identity in your Azure AD tenant. ### Step 2: Create an App Registration In the Azure portal, go to **Azure Active Directory → App registrations → New registration**: * **Name**: `KnoxCall Tenant KMS` * **Supported account types**: Single tenant * No redirect URI needed Note the **Application (client) ID** and **Directory (tenant) ID**. ### Step 3: Create a client secret In the App Registration, go to **Certificates & secrets → Client secrets → New client secret**: * **Description**: `KnoxCall BYOK` * **Expires**: 24 months (or per your policy) Note the secret **value** immediately — you'll set it as a server-side environment variable. It won't be shown again. Set the secret on the KnoxCall server environment: ```bash theme={"dark"} # On the KnoxCall server / container export AZURE_KC_CLIENT_SECRET="" ``` The value of `client_secret_env_var` you pass to the API is the **variable name** (`AZURE_KC_CLIENT_SECRET`), not the secret itself. ### Step 4: Grant access to the key Assign the **Key Vault Crypto User** role to the App Registration on the key vault: ```bash theme={"dark"} az role assignment create \ --role "Key Vault Crypto User" \ --assignee \ --scope /subscriptions//resourceGroups/my-rg/providers/Microsoft.KeyVault/vaults/my-knoxcall-kv ``` ### Step 5: Onboard via admin UI 1. Go to **Settings → Security**, find the **Tenant Master Key** card, click **Customer KMS**, then **Set up BYOK** 2. Select **Azure Key Vault** and click **Next** 3. Fill in: * **Key Vault key URL** — `https://my-knoxcall-kv.vault.azure.net/keys/tenant-wrapping-key/9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c` * **Vault URL** — `https://my-knoxcall-kv.vault.azure.net` * **Entra tenant ID** — your Directory (tenant) ID * **Client ID** — the Application (client) ID 4. Click **Next** to review, then **Confirm + activate** ### Step 6: Onboard via API ```bash theme={"dark"} curl -X POST https://api.knoxcall.com/admin/tenant-kms \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "Content-Type: application/json" \ -d '{ "provider": "azure", "kms_key_ref": "https://my-knoxcall-kv.vault.azure.net/keys/tenant-wrapping-key/9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c", "config": { "vault_url": "https://my-knoxcall-kv.vault.azure.net", "azure_tenant_id": "aaaabbbb-cccc-dddd-eeee-ffffgggghhhh", "client_id": "11112222-3333-4444-5555-666677778888", "client_secret_env_var": "AZURE_KC_CLIENT_SECRET" } }' ``` Note: `provider` is `"azure"` (not `"azure_key_vault"`). There is no `key_name` in `config` — the key name is part of `kms_key_ref`. *** ## Option B: Managed Identity (for self-hosted KnoxCall on Azure) If you run a [self-hosted KnoxCall](/infrastructure/self-hosted-proxy) on an Azure VM, App Service, AKS, or Container App with a system-assigned or user-assigned Managed Identity, you can grant that identity direct access to the vault. ### Step 2 (Option B): Grant Managed Identity access Find your Managed Identity's object ID and assign **Key Vault Crypto User**: ```bash theme={"dark"} az role assignment create \ --role "Key Vault Crypto User" \ --assignee-object-id \ --assignee-principal-type ServicePrincipal \ --scope /subscriptions//resourceGroups/my-rg/providers/Microsoft.KeyVault/vaults/my-knoxcall-kv ``` ### Step 3 (Option B): Onboard via API When `client_secret_env_var` is omitted and no explicit client credentials are provided, KnoxCall uses `DefaultAzureCredential` which picks up the Managed Identity automatically via the Azure IMDS endpoint: ```bash theme={"dark"} curl -X POST https://api.knoxcall.com/admin/tenant-kms \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "Content-Type: application/json" \ -d '{ "provider": "azure", "kms_key_ref": "https://my-knoxcall-kv.vault.azure.net/keys/tenant-wrapping-key/9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c", "config": { "vault_url": "https://my-knoxcall-kv.vault.azure.net" } }' ``` `azure_tenant_id`, `client_id`, and `client_secret_env_var` are all omitted — the Managed Identity is detected at runtime. *** ## Verify the configuration ```bash theme={"dark"} curl https://api.knoxcall.com/admin/tenant-kms \ -H "Authorization: Bearer $KC_ADMIN_JWT" ``` Confirm `config.sealed_since` is `null` and the `keys` array contains one active version. ## Session duration impact BYOK tenants have a **5-minute session TTL** (vs 1 hour for standard tenants). Self-hosted proxy agents renew sessions every 4 minutes. Ensure your infrastructure does not block frequent outbound HTTPS to `api.knoxcall.com`. ## Revoking access To lock KnoxCall out within one cache TTL (5 minutes): 1. Remove the **Key Vault Crypto User** role assignment from the App Registration or Managed Identity, **or** 2. Disable or delete the key in Key Vault Within 5 minutes, the tenant enters the [sealed state](/essentials/tenant-kms/sealed-state). No data is lost — you can unseal at any time by restoring the role assignment. ## Troubleshooting **`Forbidden` / `Authorization failed` during verify:** * Confirm the App Registration or Managed Identity has `Key Vault Crypto User` on the **vault** (not just a key-level grant) * Check the vault has RBAC authorization enabled: `az keyvault show --name my-kv --query properties.enableRbacAuthorization` **`client_secret_env_var` not found:** * The environment variable is not set on the KnoxCall server process * The env var name is case-sensitive — `AZURE_KC_CLIENT_SECRET` ≠ `azure_kc_client_secret` **Managed Identity not detected:** * Managed Identity must be enabled on the Azure resource running KnoxCall (VM → Identity → On, or `--assign-identity` flag) * `DefaultAzureCredential` queries the IMDS endpoint at `http://169.254.169.254` — ensure no firewall blocks it **`InvalidKeySize` or key operation error:** * Key Vault requires RSA 2048+ for wrap/unwrap operations * Ensure the key was created with `--ops wrapKey unwrapKey` (not `encrypt decrypt` — Azure uses different operations for RSA keys) Cross-account IAM role setup for AWS Service account impersonation for GCP What to do when Key Vault becomes unreachable Full endpoint reference # Tenant KMS: GCP Cloud KMS setup Source: https://docs.knoxcall.com/essentials/tenant-kms/setup-gcp Configure GCP Cloud KMS as your tenant wrapping key provider. KnoxCall impersonates a service account you designate — no JSON key files accepted. This guide walks you through creating a GCP Cloud KMS key, creating a service account with the required permissions, allowing KnoxCall to impersonate that service account, and onboarding it as your tenant's wrapping key. ## How GCP authentication works KnoxCall uses its own GCP credentials (configured on the KnoxCall control plane) to impersonate a **service account you create** in your GCP project. That service account is granted `roles/cloudkms.cryptoKeyEncrypterDecrypter` on your key. You never give KnoxCall your service account's key — only the right to impersonate it. ```text theme={"dark"} KnoxCall control plane → impersonates knoxcall-kms-sa@YOUR_PROJECT.iam.gserviceaccount.com → calls GCP Cloud KMS encrypt/decrypt on your key ``` ## Prerequisites * GCP project with billing enabled * Permission to create KMS key rings, keys, service accounts, and IAM bindings * KnoxCall **Owner** or **Admin** role Service account JSON key files are **rejected at the API level**. The only accepted credential model is service account impersonation granted to KnoxCall's own GCP principal. This is enforced server-side — not just a recommendation. ## Step 1: Enable the Cloud KMS API and create a key ```bash theme={"dark"} # Enable the API gcloud services enable cloudkms.googleapis.com --project=YOUR_PROJECT # Create a key ring gcloud kms keyrings create knoxcall-keys \ --location=global \ --project=YOUR_PROJECT # Create a symmetric encryption key gcloud kms keys create tenant-master-key \ --keyring=knoxcall-keys \ --location=global \ --purpose=encryption \ --project=YOUR_PROJECT ``` Note the full key resource name — you'll need it in Step 6: ```text theme={"dark"} projects/YOUR_PROJECT/locations/global/keyRings/knoxcall-keys/cryptoKeys/tenant-master-key ``` ## Step 2: Create a service account for KnoxCall Create a dedicated service account in your project that KnoxCall will impersonate: ```bash theme={"dark"} gcloud iam service-accounts create knoxcall-kms-sa \ --display-name="KnoxCall KMS access" \ --project=YOUR_PROJECT ``` This creates `knoxcall-kms-sa@YOUR_PROJECT.iam.gserviceaccount.com`. ## Step 3: Grant the service account KMS permissions Grant `roles/cloudkms.cryptoKeyEncrypterDecrypter` on the specific key (not the project or key ring — follow least privilege): ```bash theme={"dark"} gcloud kms keys add-iam-policy-binding tenant-master-key \ --keyring=knoxcall-keys \ --location=global \ --project=YOUR_PROJECT \ --member="serviceAccount:knoxcall-kms-sa@YOUR_PROJECT.iam.gserviceaccount.com" \ --role="roles/cloudkms.cryptoKeyEncrypterDecrypter" ``` ## Step 4: Allow KnoxCall to impersonate the service account KnoxCall's GCP principal needs `roles/iam.serviceAccountTokenCreator` on the service account you created. Find KnoxCall's GCP principal email in the BYOK setup wizard (**Settings → Security → Tenant Master Key → Customer KMS → Set up BYOK**, GCP step — it also shows this exact `gcloud` command pre-filled), or fetch it from `GET /admin/tenant-kms/provider-identity`, and run: ```bash theme={"dark"} gcloud iam service-accounts add-iam-policy-binding \ knoxcall-kms-sa@YOUR_PROJECT.iam.gserviceaccount.com \ --member="serviceAccount:" \ --role="roles/iam.serviceAccountTokenCreator" \ --project=YOUR_PROJECT ``` Replace `` with the email shown in the admin UI. ## Step 5: Onboard via admin UI 1. Go to **Settings → Security**, find the **Tenant Master Key** card, click **Customer KMS**, then **Set up BYOK** 2. Select **GCP Cloud KMS** and click **Next** 3. Fill in: * **Cloud KMS key resource name** — the full `projects/.../cryptoKeys/...` path from Step 1 * **Service account to impersonate** — `knoxcall-kms-sa@YOUR_PROJECT.iam.gserviceaccount.com` * **Project ID** — `YOUR_PROJECT` (optional — inferred from the service account if omitted) 4. Click **Next** to review, then **Confirm + activate** KnoxCall runs a wrap + unwrap round-trip before saving — if the probe fails, nothing is persisted. ## Step 6: Onboard via API ```bash theme={"dark"} curl -X POST https://api.knoxcall.com/admin/tenant-kms \ -H "Authorization: Bearer $KC_ADMIN_JWT" \ -H "Content-Type: application/json" \ -d '{ "provider": "gcp", "kms_key_ref": "projects/YOUR_PROJECT/locations/global/keyRings/knoxcall-keys/cryptoKeys/tenant-master-key", "config": { "impersonate_service_account": "knoxcall-kms-sa@YOUR_PROJECT.iam.gserviceaccount.com", "project_id": "YOUR_PROJECT" } }' ``` Note: `provider` is `"gcp"` (not `"gcp_cloud_kms"`). The `kms_key_ref` contains the full GCP resource path — the `config` only contains authentication references. ## Step 7: Verify the configuration ```bash theme={"dark"} curl https://api.knoxcall.com/admin/tenant-kms \ -H "Authorization: Bearer $KC_ADMIN_JWT" ``` Confirm `config.sealed_since` is `null` and the `keys` array contains one active version. ## Session duration impact BYOK tenants have a **5-minute session TTL** (vs 1 hour for standard tenants). Self-hosted proxy agents renew sessions every 4 minutes. Ensure your infrastructure does not block frequent outbound HTTPS to `api.knoxcall.com`. ## Revoking access To lock KnoxCall out within one cache TTL (5 minutes): 1. Remove the `roles/cloudkms.cryptoKeyEncrypterDecrypter` binding from the service account, **or** 2. Remove the `roles/iam.serviceAccountTokenCreator` binding that allows KnoxCall to impersonate the SA Within 5 minutes, the tenant enters the [sealed state](/essentials/tenant-kms/sealed-state). The KnoxCall control plane detects the IAM error and records the `sealed_since` timestamp. No data is lost — you can unseal at any time by restoring the IAM grant. ## Troubleshooting **`PERMISSION_DENIED` during verify:** * Confirm `knoxcall-kms-sa` has `roles/cloudkms.cryptoKeyEncrypterDecrypter` on the **key** specifically (not just the key ring or project) * Confirm KnoxCall's GCP principal has `roles/iam.serviceAccountTokenCreator` on `knoxcall-kms-sa` * Verify the service account email in `impersonate_service_account` is spelled correctly (case-sensitive) **`Error creating impersonated credentials`:** * The `roles/iam.serviceAccountTokenCreator` binding is missing or applied at the wrong resource * Run: `gcloud iam service-accounts get-iam-policy knoxcall-kms-sa@YOUR_PROJECT.iam.gserviceaccount.com` and verify KnoxCall's principal appears **Key ring or key not found:** * Ensure the location in `kms_key_ref` exactly matches the location used when creating the key ring (`global` is not the same as `us-central1`) * The full resource path is case-sensitive **Probe passes but tenant seals after onboarding:** * The service account's impersonation grant expires or is revoked by a GCP policy * Check IAM audit logs in Cloud Console for `GenerateAccessToken` denials Cross-account IAM role setup for AWS Managed Identity or Federated Credentials for Azure What to do when KMS becomes unreachable Full endpoint reference # Vaults overview Source: https://docs.knoxcall.com/essentials/vaults/overview Tokenize sensitive values (SSNs, emails, bank details, any PII) so your databases store opaque tokens instead of raw data. KnoxCall holds the values behind a per-vault encryption key. # Vaults overview KnoxCall's **Vaults** feature is a **data tokenization service**. You exchange a sensitive value (an SSN, a bank account, an email, an arbitrary blob) for a **token** — a shorter, opaque string. Your databases and analytics pipelines store the token. Whenever you need the original value, you call KnoxCall's detokenize endpoint. The Vault parallel: this is what HashiCorp calls a *Tokenization Engine*. **Vaults are not a payment-card compliance control.** KnoxCall holds no PCI DSS attestation — no AOC, no ROC, no QSA engagement — so putting card numbers in a vault does not move your cardholder-data scope; it just moves where the numbers are stored. If scope reduction is what you need, use an assessed card vault. Our current status is on the [trust page](https://knoxcall.com/trust). Use it for: * **Keeping sensitive values out of your own datastores** — your DB, logs and analytics extracts hold tokens; KnoxCall holds the values behind a per-vault key. * **PII lifecycle controls** — set TTLs so emails / SSNs auto-expire; delete one token for cryptographic erasure of that value alone, or destroy a vault to erase everything in it. * **Format preservation** — tokens that pass the same format checks as the originals (NNN-NN-NNNN for SSNs, a fixed digit shape for card-shaped columns), so existing systems keep working without schema changes. * **Centralised audit** — **every** detokenize writes an audit row naming the vault, the token, the key version and the user or key that read it. Every release path writes the same row: a plain read on the API or the dashboard, detokenization *inside* a `/v1/proxy` request (a template referencing three tokens writes three rows, not one row carrying a count), and a Route `detokenize` field action, which until 2026-09-15 released values with no row at all. The row is anchored on the **vault** (`resource_id`), carries the token in `details.token_id`, and names the destination host when the value was sent somewhere — so "where did this vault's data go, and when?" is one query. A detokenize that is **refused** is recorded too, as `vault.detokenize_refused` with the reason, because a credential repeatedly being turned away from your card vault is the thing you most want to see. ## Why use it | Problem | Without KnoxCall | With KnoxCall | | - | - | - | | Sensitive values scattered across 14 microservices | Every service holds the real value | Only the gateway resolves values; everything downstream holds tokens | | You need to make a dataset unrecoverable | Hunt every DB + backup | Delete one token → its own data key is destroyed, so that value alone becomes unopenable. Destroy the vault → the same for every token in it | | Email leak from analytics warehouse | Real emails leak | Tokenized emails leak — domain still useful for routing/cohorts | | Format-preserving = build it yourself | Hand-rolled FPE = subtle bugs | Built-in `pan` (Luhn-valid), `ssn` (9XX prefix avoids real SSNs), `email`, `generic` | ## How it works 1. **Create a vault** with a chosen [token format](#token-formats). The format is locked at creation — changing it later would invalidate every existing token. 2. **Tokenize** values: `POST /v1/vaults/{name}/tokens` with `{ value, metadata?, ttl_seconds? }`. KnoxCall returns a token string + an internal id. Store the token in your DB. 3. **Detokenize** when you need the original: `GET /v1/vaults/{name}/tokens/{token}`. Audited every time. 4. **Rotate** the vault's encryption key when you want to bump versions. Existing tokens still decrypt under their original version; new tokens encrypt under the new version. No re-encryption of stored data needed. 5. **Destroy** a vault to cryptographically shred every token in it. Permanently unreadable. ## Quick start (UI) 1. Go to **Protect → Vaults** in the admin UI and click **New Vault**. 2. Pick a **token format**: * `generic` — opaque token, safe for any value type. Default. * `pan` — credit card numbers (16 digits, Luhn-valid, BIN + last4 preserved). * `ssn` — US SSNs (9XX-XX-XXXX, last 4 preserved). * `email` — local-part tokenised, domain preserved. 3. Optional: set a **default TTL** so tokens auto-expire. 4. Save. Use the **Playground** tab to tokenize / detokenize a few values. ## Quick start (API) All responses use the standard `{ data: ..., meta: { request_id: "..." } }` envelope. The examples below show the `data` fields. ```bash theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) # 1. Create a vault for credit card numbers curl -X POST https://api.knoxcall.com/v1/vaults \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "cards", "token_format": "pan", "allowed_destinations": ["api.stripe.com"] }' # 2. Tokenize a card curl -X POST https://api.knoxcall.com/v1/vaults/cards/tokens \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "value": "4111111111111111" }' # → data: { "id": "3f1c8e2a-9b7d-4c6a-8e21-0d5f4a1b2c3d", "token": "4111110000091111", "expires_at": null, "created_at": "2026-01-20T10:00:00.000Z" } # ^^^^^^ ^^^^ # BIN preserved last4 preserved # 3. Detokenize (audited) curl https://api.knoxcall.com/v1/vaults/cards/tokens/4111110000091111 \ -H "Authorization: Bearer $TOKEN" # → data: { "id": "3f1c8e2a-9b7d-4c6a-8e21-0d5f4a1b2c3d", "token": "4111110000091111", "value": "4111111111111111", # "value_b64": "NDExMTExMTExMTExMTExMQ==", "metadata": null, # "expires_at": null, "created_at": "...", "crypto_key_version": 1 } ``` ## Token formats | Format | Shape | Preserves | Validates as | Best for | | - | - | - | - | - | | **generic** | `tok_` | nothing — opaque | nothing — opaque | Default. JSON blobs, free-text PII, API tokens. | | **pan** | 16 digits | first 6 (BIN) + last 4 | Luhn-valid 16-digit number | Card-shaped DB columns that must keep validating. Not a compliance control — see the warning above. | | **ssn** | NNN-NN-NNNN | last 4 | SSN-shape | US contractor / customer onboarding. Token always starts with 9 (SSA reserves 9XX, so guaranteed not to collide with real SSNs). | | **email** | `@` | domain (lowercased) | RFC-shape email | Marketing/analytics that key on email domain but shouldn't see local-parts. | The `pan` and `ssn` generators solve the math so the resulting token validates: PAN tokens pass the Luhn check, SSN tokens fit the regex your form already enforces. You drop the token into the same column the original would have gone in. ### `pan` vaults are destination-bound, and cannot be read in the clear A `pan` vault is the one format with two extra rules, both enforced in the vault service so no caller can route around them: * **It requires a non-empty `allowed_destinations` list at creation**, naming the hosts a card number may be sent to (e.g. `["api.stripe.com"]`). The list cannot later be cleared. Creating one without it is refused. * **It refuses the plain read.** `GET /v1/vaults/{vault}/tokens/{token}`, its `/admin` twin and the browser capability consume all ask for a value with no destination attached — and a `pan` vault answers all three with a refusal. The number leaves only through **detokenize-and-send**, where KnoxCall knows the host it is about to reach: * `POST /v1/proxy` resolving a `{{ token: … }}` towards the URL in `X-Knox-Proxy-URL`, or * a **Route** with a request-direction `detokenize` field action, which names that route's own resolved upstream host. A destination not on the list is refused *before* anything is decrypted. Moving a route that carries a `detokenize` action (either direction) to a new upstream is an Owner or Admin act with a fresh second factor, or, on `/v1`, needs `vaults:detokenize` — see [Moving a route that decrypts or detokenizes](/essentials/routes/creating-routes#moving-a-route-that-decrypts-or-detokenizes). A Route's **response**-direction `detokenize` is still a plain read and is still refused: the value there would be handed back to the caller, not sent to a processor, which is the one thing a `pan` vault never does. Absence of a destination is treated as a plain read rather than as "unknown", so a future caller that forgets to say where the value is going gets the stricter answer, not the weaker one. ### Card numbers only go in `pan` vaults The rules above are what makes a `pan` vault different, and they are worth nothing if a card number can simply be tokenized somewhere else. So it cannot be: **a value that looks like a payment card number is refused by every vault whose `token_format` is not `pan`**, with `400 wrong_format` and a message naming the format to use. The refusal covers `value` and every string inside `metadata` — a number in `metadata.card_number` is stored just as surely as one in `value`. It applies wherever a value is tokenized: `POST /v1/vaults/{vault}/tokens` and its bulk twin, the `/admin` pair, a Route's `tokenize` field action, and the browser capability consume behind a hosted field. In a **bulk** call the whole batch is refused — nothing is stored for any value in it — and the message names the index of the offending element. A value is treated as a card number when it is 13-19 digits (spaces and hyphens are ignored), passes the Luhn check, **and** begins in a range cards are actually issued in. All three are required: a Luhn-valid sixteen-digit order reference such as `7000000000000005` is not in an issued range and tokenizes normally, and `4111111111111112` is in one but fails Luhn, so it tokenizes too. We refuse rather than accept because a card number in a `generic` vault would sit in KnoxCall's custody carrying none of the protections above — no destination binding, no plain-read refusal, no format-preserving token — and outside whatever boundary anyone later draws around card data. There is no flag to turn this off: it is what keeps a tenant who never uses the card format out of that boundary entirely. ### A processor that echoes the card number back When a Route's **request**-direction `detokenize` opens a `pan` vault, the real card number goes to the upstream on purpose — that is the whole point of detokenize-and-send. Many processors then echo it straight back in their confirmation object, in a `Location` redirect or in a plain-text receipt. KnoxCall scans the reply for that exact number and puts the token back before the response reaches you — and before it reaches the four other places that copy a response: your outbound **webhooks**, the **workflow** event that Route responses trigger, the S3 **response archive**, and the `response_body` field of the request log. None of the five sees the card number. (The two that are also log surfaces — `response_body` and the webhook payload — mask any card-shaped value to its last four on top of this, so what you read there is `••••••••••••1111` rather than the token itself.) Scope is deliberately narrow and exact: * **Only the values this request opened.** A different card number the processor mentions is your own data and is left alone; this is not a general card scan. * **Only `pan` vaults** — a `generic`, `ssn` or `email` value echoed back is untouched. * **However the processor spelled it** — spaces, dashes and the form-encoded `+` / `%20` forms all match, in JSON string and number fields at any depth, in object keys, in form and text bodies, and in header values. Two consequences to plan for: * A request that detokenizes a `pan` token is always answered **buffered**, even if the caller asks for `text/event-stream`: a card number can straddle any chunk boundary the upstream picks, so a streamed reply cannot be re-tokenized honestly. * If the reply cannot be read at all — an encoding KnoxCall does not decode, a binary body, or a body past the response-log ceiling — the response is withheld with `502 relay_response_not_retokenizable` rather than delivered unscanned. **Do not blindly retry** — the upstream call already happened. Each re-tokenized response writes a `vault.retokenize` audit entry naming the vault and the occurrence count — never the number and never the token. A non-zero count is worth acting on: it means your processor is echoing card data. ### KnoxCall alerts you when card data goes somewhere new Every successful detokenize is counted against **where the value went** — the destination pattern it matched on the vault's `allowed_destinations` list, or the exact hostname when no pattern covers it. The counter holds the tenant, the vault, the data space, that destination bucket, a five-minute window and a count, and nothing else: never the token, never the value, never the last four digits. Two things then raise an alert. The alert itself — **Vault Destination Monitoring** — is installed for you the moment your workspace has its first vault, and it uses whichever notification channels your other alerts use. You do not need a Route, and you do not need to configure anything: * **A destination this vault has never used.** A detokenize to a bucket with no prior window in the last 30 days. A card number arriving at a processor you have never sent to is the first signal of a stolen credential, and it fires on the very first one — you do not have to wait for a pattern to form. * **A spike over that destination's own normal.** The current window's count measured against the baseline KnoxCall learned for that vault-and-destination pair, using the same statistics the rest of the anomaly detection uses. A quieter-than-usual window is not an incident and never fires. **The alerts are about Live traffic only.** Test-mode detokenizes are counted in their own separate history — so a test run can never teach the detector that an unfamiliar host is familiar, and a Live destination you have used for months is still a first for Test — but they never raise an alert. Pointing your sandbox at a new endpoint is development, not an incident, and KnoxCall does not page you for it. Each window raises each alert at most once, so a burst of detokenizes to one new destination is one notification rather than one per request. ### KnoxCall tells you before a stored card expires A card on file stops working the day it expires, and **nothing on KnoxCall's side errors when it does** — we hold a token, not an issuing relationship with the card's bank. The first anyone hears about it is a decline at your payment processor, usually on a renewal, with no mention of an expiry date anywhere in the message. So a card token can carry the **card's own expiry**, and KnoxCall raises an event **60 days and again 30 days** before it, once each: ```bash theme={"dark"} curl -X POST https://api.knoxcall.com/v1/vaults/cards/tokens \ -H "Authorization: Bearer $KC_TOKEN" -H "Content-Type: application/json" \ -d '{"value":"4111111111111111","card_exp_month":7,"card_exp_year":2029}' # → data: { "id": "…", "token": "4111114725124116", "expires_at": null, # "created_at": "…", "card_expires_on": "2029-07-31" } ``` `card_exp_month` (1–12) and `card_exp_year` (**four digits** — `2029`, never `29`) are optional, go together, and are accepted only by a `pan` vault. Half an expiry, a two-digit year, or either field sent to a `generic` / `ssn` / `email` vault is refused with `400 validation_error` rather than quietly dropped — a quiet drop means no notice ever fires and you never find out. `card_expires_on` comes back as the **last day of the expiry month**, because a card is valid through the end of its month. You will also see it on every row of `GET /v1/vaults/{vault}/tokens`. **This is not the token's TTL.** `ttl_seconds` and `expires_at` are how long *KnoxCall's token* lives; `card_exp_*` and `card_expires_on` are when *the card* stops working. They are unrelated — a token with no TTL at all can belong to a card that dies in fourteen months. **Subscribe a webhook to `vault.token.expiring`** to receive the notice: ```json theme={"dark"} { "event": "vault.token.expiring", "timestamp": "2026-09-17T08:30:04.117Z", "data": { "token_id": "0f4a…", "token": "4111114725124116", "vault_id": "22c1…", "vault_name": "cards", "last4": "4116", "card_expires_on": "2029-07-31", "days_until_expiry": 60, "threshold_days": 60, "sandbox": false } } ``` `token` is the token you already store — for a `pan` vault it preserves the real BIN and last four, which is what makes "your card ending **4116** expires next month" possible without anyone holding the number. **The payload never carries the card number**, and that is enforced rather than promised: a delivery whose contents look card-shaped is refused server-side instead of sent. Every notice is also written to your audit log as `vault.token.expiring` against the token's id, so a workspace already piping `audit.event` into a SIEM receives it without subscribing to anything new. A Test-mode card only ever reaches a Test-mode webhook. The notice is decided by a job that runs once a day, and it uses a **window** rather than an exact day: if a run is missed, the notice catches up on the next one instead of being skipped. A "60 days" event can therefore arrive a few days late. It never arrives twice. **Tokens created before 17 September 2026 carry `card_expires_on: null`, and there is no backfill.** The expiry was never captured, so it is not in the stored ciphertext and KnoxCall cannot recover it — those tokens get no notice until the card is tokenized again. If you hold cards on file and want the notices, re-tokenize on the next successful charge or the next time the customer updates their details. The same applies to any token created without the two fields, and to the `tokenize` **field action on a Route**, which sees one field value and has no expiry to carry. ### Test cards **Test mode has card numbers that fail on purpose**, so you can exercise your decline path, your 3-D Secure path and your expiry path before the first real card. They are ordinary card numbers in a `pan` vault — you tokenize them the way you tokenize anything — and KnoxCall recognises them **only in the Test data space**. | Number | What happens on detokenize-and-send | | - | - | | `4242 4242 4242 4242` | Forwarded to your destination exactly like a real card. | | `4000 0000 0000 0002` | **Not forwarded.** `402 sandbox_test_card_declined`. | | `4000 0000 0000 3220` | **Not forwarded.** `402 sandbox_test_card_requires_action` — the shape a card that needs 3-D Secure takes before authentication. | | `4000 0000 0000 0069` | Forwarded normally, but **tokenizes with `card_expires_on: "2020-01-31"`** when you supply no `card_exp_*` of your own. | The two refusals answer the ordinary `/v1` error envelope on `POST /v1/proxy` (and on `/wg` and your wrap subdomain), and the ordinary Route envelope on a configured Route: ```json theme={"dark"} { "error": { "type": "sandbox_test_card_declined", "message": "Not forwarded. This request released the KnoxCall sandbox test card ••••••••••••0002, which the Test data space simulates as a DECLINE. Nothing was sent to api.stripe.com and this is KnoxCall's own simulated outcome, not a response from it or from any payment provider. In the Live data space this number is an ordinary card and is forwarded normally.", "request_id": "3f1c8e2a-…" } } ``` The response also carries `X-Knox-Error: sandbox_test_card_declined` and **no** `X-Knox-Destination-Status`, which is how a wrapped provider SDK tells a KnoxCall answer from its provider's — so `stripe-node` sees a KnoxCall error, never a Stripe decline. **This is a simulation, and it is KnoxCall's shape — not any processor's.** KnoxCall is a vault and a proxy; it does not authorize, capture, or hold an issuing relationship, so it cannot tell you what *your* processor would have said. What it can do is refuse to forward and say exactly why. Nothing is sent upstream on a decline or a 3-D Secure outcome: **no request reaches your destination at all.** **402 is also `plan_limit`.** Branch on `error.type` (or `X-Knox-Error`), never on the status alone. Four things worth knowing: * **The Live data space treats all four as ordinary card numbers.** There is no Live behaviour here to switch off — the check returns before it reads anything when the data space is Live. * **Only a value a vault released is classified.** Pasting `4000 0000 0000 0002` into a request body as clear text is refused by the raw-card-number egress control (`403 raw_pan_egress_refused`), exactly as any other card number in a clear body is. Test cards are not a way around that. * **The card still leaves the vault.** A simulated decline writes the same single `vault.detokenize` audit row, counts against the same detokenize meter, and appears in the vault's **Destinations** view attributed to the destination you named — because the number really was decrypted. Only the forward is withheld. * **`4000 0000 0000 0069` is not simulated at all.** Its `card_expires_on` is a real date in a real column, so the tokenize response, the token list and the expiry job all read it the way they read a real card's. Supply your own `card_exp_month` / `card_exp_year` and yours wins — KnoxCall never rewrites an expiry you spelled out. **An already-expired card gets no `vault.token.expiring` notice**, and that is not specific to the test card. The 60- and 30-day windows look *forward*, so a card whose expiry has passed is in neither. The test card proves the expiry is captured, stored and readable; it cannot be used to make a notice fire on demand. ### BIN intelligence: two fields that ship empty A card token also carries two fields describing the **issuing bank**, derived from the card's first six digits: | Field | Shape | | - | - | | `card_funding_type` | `"credit"` / `"debit"` / `"prepaid"`, or `null` | | `card_issuing_country` | ISO 3166-1 alpha-2, upper case (`"IE"`), or `null` | They appear on the tokenize response, the bulk tokenize response and every row of `GET /v1/vaults/{vault}/tokens`, beside `card_expires_on`. There is nothing to send: both are derived at tokenize time, never supplied. **Both are `null` on every token today, and we would rather say so than let you discover it.** Naming a card's funding type and issuing country from its first six digits requires a commercially licensed BIN table. KnoxCall has not licensed one, and whether we do is an open business decision. Everything around that table is built and shipped — the storage, the lookup, the API shape, the SDK models — so on the day we license one the fields populate with no change to your integration. Until then, expect `null`. **Treat both as optional indefinitely.** Even with a table in place they are `null` for every non-`pan` vault and for any card whose issuer the table does not carry. Code that *requires* either field to be present will break on cards that are perfectly valid. **`brand` and the last four digits are a different thing and have always worked.** Those come from the *published* issuer ranges, which need no licence — and for a `pan` vault the token string itself preserves the real BIN and last four, which is how "your card ending 4116" works without anyone holding the number. Only the first **six** digits are ever used for this lookup, and that is a boundary in the code rather than a habit: the truncation and the lookup live in one module, and the lookup refuses anything that is not exactly six digits. ### Why a Route field action refused your request A Route can carry **field actions** — `tokenize`, `detokenize`, `encrypt`, `decrypt` applied to selected JSON fields on the way out or on the way back. When one of them cannot run, KnoxCall **refuses the request**: the upstream is not called with the untransformed value, and the upstream's untransformed reply is not delivered to you. That part never varies — the whole point of the action is that the raw value does not travel. What the answer tells you is *why*, and whether retrying can possibly help: | Code | Request direction | Response direction | What happened | What to do | | - | - | - | - | - | | `relay_token_expired` | `422` | `502` | A token in the body has passed its TTL. | Tokenize the value again; the old token is gone for good. | | `relay_destination_refused` | `403` | `502` | The route's upstream host is not on the vault's `allowed_destinations` — or the action is on the **response** direction, which is a plain read a `pan` vault never answers. | Add the route's upstream host to the vault's `allowed_destinations` (a `*.suffix` entry works), or move the action to the request direction. | | `relay_plan_limit` | `402` | `502` | Your plan's monthly vault-operation allowance is used up. | Upgrade, or wait for the meter to roll over. | | `relay_vault_disabled` | `409` | `502` | The vault named by the action has its **enabled** switch off. | Re-enable the vault in the dashboard. | | `relay_value_rejected` | `422` | `502` | The value at a selected field is not one the vault's format accepts — a non-Luhn PAN at a `pan` vault, a malformed SSN or email, or a **card number at a vault that is not `pan`**. | Fix the value — or, for a card number, point the action at a `pan` vault. Never at a `generic` one: that is the case being refused. | | `relay_token_space_exhausted` | `409` | `502` | The format-preserving token space for that specific input is full. | Stop retrying *that* input — a distinct token cannot be issued for it. | | `relay_unavailable` | `503` | — | KnoxCall could not run the action: a key, a KMS or the database. | Retry. This one, and only this one, is safe to retry. | | `relay_response_undeliverable` | — | `502` | The same, on the reply. | **Do not blindly retry** — the upstream call already happened. | | `relay_response_not_retokenizable` | — | `502` | The request detokenized a **card number**, and the upstream's reply could not be scanned for it — compressed with an encoding KnoxCall does not decode, binary, or past the response-log ceiling. The reply was withheld rather than delivered unscanned. | Have the upstream answer uncompressed JSON/text, or move the `pan` detokenize to a route whose upstream does. **Do not blindly retry.** | Three rules hold across the whole table: * **A `4xx` is final.** Replaying the identical request is refused identically. `503` is the only code that means "try again". * **The response direction is always `502`**, whatever the reason, because the upstream request *was performed*. A blind retry of a `POST` that already charged a card is its own incident. * **Every refusal is in your audit log**, as `route.action.refused` with the reason and the `request_id` from the response body. Genuine KnoxCall faults are `route.action.failed` instead, so the log answers "was this us or them?" without guesswork. ## Encryption + rotation Each vault has its own encryption key (managed via [Crypto Keys](/essentials/crypto-keys/crypto-keys-overview)). Tokens carry the key version they were encrypted under. When you rotate: * A new key version becomes active. * New tokens encrypt under the new version. * Existing tokens still decrypt under their original version (no migration needed). Destroying a vault destroys every key version. Tokens become permanently unreadable. This is **cryptographic erasure** — the data is unrecoverable even if the encrypted ciphertext is leaked from a backup. **Deleting a single token is an erasure too, not just a row delete.** Each token's value is sealed under that token's *own* data key, and the vault key wraps only the data key. Deleting the token destroys the data key and removes the row in one transaction — so a copy of the row taken from a backup, a replica or a WAL segment *before* the delete can no longer be opened, even by someone who also holds the vault key. That is what makes a per-customer erasure request answerable without destroying the whole vault. ## Bulk and TTL * **Bulk tokenize**: up to 1000 values per call via `POST /v1/vaults/{name}/tokens/bulk`. The batch runs in **one transaction** — all of it is stored or none of it is, so a failure partway through leaves nothing behind to reconcile. * **TTL**: per-vault default + per-call override. Expired tokens 404 on detokenize and get pruned by an hourly cron. This is the **token's** lifetime and has nothing to do with `card_expires_on`, the **card's** expiry date — see "KnoxCall tells you before a stored card expires" above. * **Metadata**: attach a small JSON object alongside each token (read back on detokenize). Encrypted at rest. ## See where your data went Every vault's detail page has a **Destinations** tab — "Where this data went". It answers, for the last 7, 30 or 90 days: * **Which hosts** a value was released to, one row per host. * **How many** releases went to each, and **how many per day**, so a sudden spike to a host you do not recognise is visible rather than buried. * **First seen** and **last seen** per host. * A link straight into the **audit trail** for that vault, and a **Download as CSV** of the table. It is derived from the `vault.detokenize` audit rows themselves — there is no second ledger that could disagree with the log an auditor reads. Nothing on that screen contains a token, a value or the last four digits of anything; the rows name hosts and counts. For a `pan` vault the list is bounded by `allowed_destinations`: a host that is not on the list could not have received a card number, which is why the page says so above the table. One egress path is not in this table yet: a **Route** with a request-direction `detokenize` field action releases a value without writing a per-token audit row, so it does not appear. The page states this beneath the table rather than letting an empty table read as "nothing left". `POST /v1/proxy` and the plain reads through `/v1` and the dashboard are all counted. ## Plan limits | Tier | Vaults | Tokens | Format-preserving | Tokenize ops/mo | Detokenize ops/mo | | - | - | - | - | - | - | | Free | 1 | 1k | generic only | 1k | 1k | | Starter | 5 | 50k | generic only | 10k | 10k | | Pro | 25 | 1M | all (`pan`, `ssn`, `email`) | 1M | 1M | | Enterprise | unlimited | unlimited | all | unlimited | unlimited | ## Who can list tokens and detokenize Listing a vault's tokens (their strings keep a card's BIN and last four) and detokenizing a value are both reveals. In the dashboard this is a **Class B reveal**: only an **Owner** or an **Admin** can do it (an Editor cannot, even with **Reveal secrets** turned on; that setting covers secret values, certificates and credentialed workflow tests only), and every use asks for a fresh second factor (an authenticator code, a passkey or a security key; an emailed code does not count). Each use writes a `plaintext.reveal` entry to the audit log **before** anything is shown, and counts against the same per-person reveal budget as showing a secret's value (60 per 5 minutes). If the audit entry cannot be written, or the budget cannot be counted, the request is refused rather than served. On the Management API (`/v1`) a credential still needs the matching permission. A token issued to a person never carries `vault:detokenize`; listing a vault's tokens on `/v1` needs `vault:read` and does not take this check. The dashboard no longer lists a vault's tokens when you open it: click **Show tokens** on the **Tokens** tab. A token you delete is removed from the list on screen without listing again. ## Next steps * [Crypto Keys overview →](/essentials/crypto-keys/crypto-keys-overview) — the encryption layer Vaults builds on * [API reference →](/api-reference/overview) # Webhook Signing overview Source: https://docs.knoxcall.com/essentials/webhook-signing/overview Sign outbound webhook events in any of six HMAC formats — Stripe, GitHub, Slack, AWS-SNS-HMAC, KnoxCall legacy, or a custom header. The recipient verifies the signature and rejects forgeries. # Webhook Signing overview KnoxCall's **Webhook Signing** is HMAC-as-a-service for outbound events. When KnoxCall fires a webhook (workflow output, alert dispatch, custom integration), the payload is signed with HMAC-SHA256 in the format the recipient expects. They verify the signature and trust the bytes; nobody can forge an event without the shared secret. This is the inverse of [Inbound Webhooks](/essentials/inbound-webhooks/overview) — same wire formats, opposite direction. ## Why use it | Problem | Without signing | With KnoxCall signing | | - | - | - | | Receiver gets a webhook with a sensitive payload | Anyone who learns the URL can forge events | Without the secret you can't forge a signature; receiver rejects | | Each integration uses a different signing scheme | One-off code per recipient | Pick a format from the dropdown; KnoxCall emits headers the recipient already validates | | Rotating the signing secret | Update KnoxCall + the recipient atomically (race) | Stripe-format accepts multiple `v1=` simultaneously — drop in the new secret, rotate the upstream, retire the old one | | Replay attacks | Receiver re-runs the same event twice | Formats with timestamps (Stripe / Slack) include `t=` so the receiver enforces a 5-minute window | ## Supported formats Same six formats as [Inbound Webhooks verification](/essentials/inbound-webhooks/format-guide): | Format | Header | Replay-safe? | Use case | | - | - | - | - | | **stripe** | `Stripe-Signature: t=,v1=` | yes | Stripe-compatible receivers, anything that adopted the format | | **github** | `X-Hub-Signature-256: sha256=` | no | GitHub, Postmark, SendGrid-shape receivers | | **slack** | `X-Slack-Signature: v0=` + timestamp | yes | Slack-compatible event handlers | | **aws-sns** | `x-amz-sns-signature: ` | no | KnoxCall-shaped SNS receivers (not real AWS RSA-SNS) | | **legacy** | `X-Webhook-Signature: sha256=` | no | KnoxCall-to-KnoxCall flows; older subscribers | | **custom** | caller-named, `sha256=` | no | Internal tools that don't match a named scheme | The **killer test** that proves the formats are right: KnoxCall's `stripe`-format output is verified end-to-end by Stripe's own SDK in the test suite. If `stripe.webhooks.constructEvent` accepts our header, the wire format is byte-correct. The same kind of round-trip test exists for every format, and KnoxCall's own [Inbound Webhooks](/essentials/inbound-webhooks/overview) verifier is the inverse — outbound + inbound share the same six format definitions, so the round-trip is structurally guaranteed. ## How recipients verify Every format follows the same pattern: HMAC-SHA256 over a defined byte sequence (often the raw body, sometimes prefixed with a timestamp), encoded as hex or base64, dropped into a known header. The recipient recomputes the HMAC with the shared secret and compares constant-time. For the wire-level details (what bytes get signed, what the header looks like, replay-window semantics), see the [Inbound Webhooks format guide](/essentials/inbound-webhooks/format-guide) — both directions speak the same protocol. ## Where signing happens in KnoxCall Webhook Signing applies anywhere KnoxCall fires HTTP events outward: * **Workflows** — emit signed events to integrations. * **Alerts** — sign Slack-format webhook deliveries to your incident channel. * **Custom dispatcher** — pick a format on the subscription; KnoxCall handles the signing. The format is per-subscription, not global, so different recipients can each get the format they expect. ## Choosing a format * **Talking to a real provider that documents a scheme?** Use the matching format. Stripe receivers expect `stripe`, Slack receivers expect `slack`, GitHub-shaped receivers expect `github`, etc. * **Talking to your own service?** Use `legacy` (KnoxCall's default) for KnoxCall-to-KnoxCall flows, or `custom` if you want to pick the header name yourself. * **Talking to an integration that doesn't document a scheme?** `legacy` or `custom` — most receivers will accept "HMAC-SHA256 of the body in this header" if you tell them which header to read. ## Plan limits Multi-format webhook signing (everything except `legacy`) is gated to paid tiers — Pro and above. Free / Starter ship with `legacy` only. See [pricing](https://knoxcall.com/pricing) for current limits. ## Next steps * [Inbound Webhooks overview →](/essentials/inbound-webhooks/overview) — verify HMAC-signed webhooks coming *in* from external providers * [Format guide →](/essentials/inbound-webhooks/format-guide) — wire-level details (shared with inbound) # Set Up KnoxCall with Your AI Agent Source: https://docs.knoxcall.com/getting-started/ai-agent-onboarding A step-by-step runbook your coding agent (Claude Code, Cursor, etc.) can follow to create a KnoxCall account and integrate your codebase — end to end, no browser required. This page is written to be executed by an AI coding agent. If you're a human: paste the prompt below into your agent and it can do everything else. If you're an agent: follow the runbook top to bottom. ## For humans — the one-liner Paste this into Claude Code (or any coding agent with shell access) from inside your project: ```text theme={"dark"} Set up KnoxCall for this project. Follow the runbook at https://docs.knoxcall.com/getting-started/ai-agent-onboarding — create an account with my email , proxy this project's outbound API calls through KnoxCall in Test mode, and show me the verification steps. ``` Your agent will start the account, then wait for you to do one thing before it can go any further: **click the sign-in link KnoxCall emails you**. That click is what creates the workspace and releases the agent's test credential. After that it builds the whole integration against the isolated Test sandbox on its own, and comes back to you once more to approve the switch to Live mode. ## For agents — the runbook `POST https://api.knoxcall.com/v1/signup` is unauthenticated. It does **not** return a credential: it returns a claim handle and emails a sign-in link to the owner. ```bash theme={"dark"} curl -X POST https://api.knoxcall.com/v1/signup \ -H "Content-Type: application/json" \ -d '{ "email": "owner@company.com", "tenant_name": "Company Name", "country": "US" }' ``` The reply is always `202`: ```json theme={"dark"} { "data": { "status": "pending", "claim_handle": "sck_…", "claim_path": "/v1/signup/claim", "poll_after_seconds": 5, "expires_at": "2026-08-29T09:14:22.117Z" } } ``` Notes: * Store `data.claim_handle`. It is a **secret** — it is what collects the API key — and it is valid for 24 hours. * Omit `tenant_slug` — the server derives an available one. Don't iterate on slug collisions yourself. * Rate limit: 3 signups per hour per IP. Do not retry a 4xx. * The reply is identical whether or not the address already has an account. Deliberately: it means this endpoint cannot be used to find out who has a KnoxCall account. It also means a `202` is not proof the workspace will be created — only the click is. Stop and tell the user, in these words: *"I've started your KnoxCall signup. Click the sign-in link in the email from KnoxCall — that creates your workspace and releases my test credential. I'll wait."* Then poll, no faster than `poll_after_seconds`: ```bash theme={"dark"} curl -X POST https://api.knoxcall.com/v1/signup/claim \ -H "Content-Type: application/json" \ -d '{"claim_handle": "sck_…"}' ``` * `202` with `"status": "pending"` — the link has not been clicked yet. Keep waiting; this is a normal success, not an error. * `200` with `"status": "ready"` — the workspace exists. `data.starter.api_key.api_key` is a **one-time plaintext test key**. It is never shown again, and a second poll returns `409`. * `404` — the handle is unknown or expired. Start again at the previous step. From the `200` response, store these three values now — the key in the user's secret manager or an untracked `.env.local`, never in a tracked file: ```bash theme={"dark"} KNOXCALL_TEST_KEY='tk_…' # data.starter.api_key.api_key (the one-time key) KNOXCALL_KEY_ID='tk_…' # data.starter.api_key.key_id (its OAuth client_id) KNOXCALL_TENANT_SLUG='…' # data.tenant.slug ``` The account is on the free plan and the tenant is in Test mode. Everything below works now. The claim response includes `data.starter.curl`: two commands that exchange the starter key for a one-hour access token and call a seeded demo route (`getting-started`, proxying httpbin.org) with it. Run it verbatim — it needs only `curl` and `sed`, not `jq`. A JSON echo from httpbin proves the proxy, your credential, and the tenant are all live. Scan the project for outbound API calls and their credentials: HTTP client calls (fetch/axios/httpx/requests/Guzzle), base URLs in config, and API keys in `.env` files. Each distinct upstream API becomes one KnoxCall **route**; each credential becomes one **secret**. The test key itself does **not** authenticate API calls. Every new workspace is in OAuth-only mode, so `Authorization: Bearer tk_…` answers `401 use_oauth` on the Management API, and the proxy refuses it too. The key is an OAuth client instead: its `client_id` is `KNOXCALL_KEY_ID`, and its `client_secret` is the rest of the key after `_`. Exchange it for a one-hour access token at the sandbox token endpoint — the same exchange `data.starter.curl` does: ```bash theme={"dark"} KNOXCALL_CLIENT_SECRET="${KNOXCALL_TEST_KEY#${KNOXCALL_KEY_ID}_}" TOKEN=$(curl -s -X POST https://sandbox.knoxcall.com/oauth/token \ -u "$KNOXCALL_KEY_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" \ | sed -nE 's/.*"access_token" *: *"([^"]+)".*/\1/p') ``` The token (`kc_test_…`) lasts `expires_in` seconds (one hour) and comes with no refresh token. When a call answers `401` with `invalid_token`, or the hour is up, run the exchange again. Send the `tk_` key to the token endpoint and nowhere else. With the token, `https://sandbox.knoxcall.com/v1` is the Test-mode Management API. Full reference: [API Reference](/api-reference/overview), OpenAPI spec at `/api-reference/openapi.yaml`. For each upstream API: ```bash theme={"dark"} # 1. A secret for the upstream's credential curl -s -X POST https://sandbox.knoxcall.com/v1/secrets \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"name": "stripe-api-key", "value": "sk_test_..."}' # 2. A route to the upstream that injects the secret ROUTE_ID=$(curl -s -X POST https://sandbox.knoxcall.com/v1/routes \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "stripe", "target_base_url": "https://api.stripe.com", "inject_headers_json": {"Authorization": "Bearer {{secret:stripe-api-key}}"} }' \ | sed -nE 's/.*"id" *: *"([^"]+)".*/\1/p') # 3. Test mode only: accept calls without a client allowlist, like the seeded # getting-started route curl -s -X PATCH https://sandbox.knoxcall.com/v1/routes/$ROUTE_ID \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"requires_clients": false}' ``` * The injection field is `inject_headers_json`. `POST /v1/routes` ignores fields it does not know rather than rejecting them, so a misspelled field such as `inject_headers` creates a route that injects nothing, and the upstream answers with its own `401`. * A new route starts with `requires_clients: true`: only requests from authorized clients (by IP or mTLS certificate) are accepted, and until one is attached every call answers `403` "No authorized clients are configured". Attaching a client to a route is done in the dashboard, not through `/v1`, which is why step 3 turns the allowlist off for the Test route instead. The owner decides the Live setting (last step). * Prefer `{{secret_id:UUID}}` over `{{secret:NAME}}` in anything you persist — the UUID is `data.id` in the secret's create response. Names break on rename; UUIDs don't. * The free plan caps how many routes and secrets each mode can hold (the seeded starter route does not count). A create past the cap answers `402 plan_limit`: stop and tell the user rather than retrying. Exactly three changes per integration — see the [AI Integration Guide](/advanced/ai-integration-guide) for worked before/after examples and the path-concatenation rules: 1. Replace the API base URL with `https://sandbox-{tenant_slug}.knoxcall.com/api` (Test) — the path after the upstream's base URL stays as-is. 2. Send `Authorization: Bearer ` and `x-knoxcall-route: `. The application mints its own token with the same client-credentials exchange (client id and secret read from its environment, re-minted before the hour runs out), or uses an SDK, which does that for it. KnoxCall removes its own `Authorization` header before forwarding and injects the route's. 3. Remove the old credential from the code and env files — KnoxCall injects it server-side now. Check one rewritten call from the shell: ```bash theme={"dark"} curl -s https://sandbox-$KNOXCALL_TENANT_SLUG.knoxcall.com/api/v1/customers \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: stripe" ``` Routes are identified by the `x-knoxcall-route` header, **not** by path matching. Paths concatenate: `target_base_url.pathname + request.path`. Do not duplicate the base path. `x-knoxcall-key: ` is the legacy way to authenticate a proxy call. It works only in a workspace whose owner has turned OAuth-only mode off, and every new workspace refuses it. Do not write it into new code. Run the project's test suite or exercise the changed call sites. Proxied requests appear in the request log, `GET https://sandbox.knoxcall.com/v1/logs` with the same Bearer token; configuration changes are in `GET https://sandbox.knoxcall.com/v1/audit-logs`. Then tell the user, explicitly: 1. **Log in at [https://knoxcall.com](https://knoxcall.com)** (the sign-in link they already clicked verified the address and opened the dashboard). 2. Switch to Live mode, recreate the secrets with production credentials, recreate the routes, and mint a Live API key from API Keys. A Live key is an OAuth client in exactly the same way: the application exchanges it at `POST https://api.knoxcall.com/oauth/token`, and a Live token calls `https://api.knoxcall.com/v1` and the Live proxy host. 3. On each Live route, attach the application's servers as authorized clients, or decide deliberately to leave the client allowlist off. 4. Swap `sandbox-{slug}` → `{slug}` in the proxy host, the token endpoint host to `api.knoxcall.com`, and the test client id and secret for the Live ones — via the environment config, not a code change. Do not attempt to mint Live keys or move production credentials yourself unless the user explicitly asks. ### The same flow with the Node SDK `npm install @knoxcall/sdk`. The client takes the key as an OAuth client id and secret, and mints and re-mints the access token itself: ```ts theme={"dark"} import { signup, claimSignup, KnoxCall } from "@knoxcall/sdk"; const started = await signup({ email: "owner@company.com", tenant_name: "Company Name" }); // …the owner clicks the sign-in link KnoxCall emailed… let claim = await claimSignup({ claim_handle: started.claim_handle }); while (claim.status === "pending") { await new Promise((r) => setTimeout(r, started.poll_after_seconds * 1000)); claim = await claimSignup({ claim_handle: started.claim_handle }); } // claim.starter.api_key.api_key is shown exactly once — store it now. const { key_id, api_key } = claim.starter.api_key; const knox = new KnoxCall({ clientId: key_id, clientSecret: api_key.slice(key_id.length + 1), sandbox: true, }); await knox.secrets.create({ name: "stripe-api-key", value: process.env.STRIPE_SECRET_KEY! }); const route = await knox.routes.create({ name: "stripe", target_base_url: "https://api.stripe.com" }); await knox.routes.update(route.id, { inject_headers_json: { Authorization: "Bearer {{secret:stripe-api-key}}" }, requires_clients: false, // Test mode only; see the build step above }); const res = await knox.call("stripe", { path: "/v1/customers" }); ``` ## Contract summary for tools | What | Value | | - | - | | Signup | `POST https://api.knoxcall.com/v1/signup` — unauthenticated, JSON `{email, tenant_name}` minimum. Always `202` + `claim_handle`; no credential | | Claim | `POST https://api.knoxcall.com/v1/signup/claim` — unauthenticated, JSON `{claim_handle}`. `202` until the emailed link is clicked, then `200` once with the test key; `404` unknown or expired, `409` already collected | | Credential | The test key is an OAuth client: `client_id` = `data.starter.api_key.key_id`, `client_secret` = the rest of `data.starter.api_key.api_key` after `_`. The raw key is accepted only by the token endpoint. Anywhere else it is refused with `401`: `use_oauth` on `/v1`; on the proxy, `This tenant requires OAuth 2.1 tokens` when sent as `x-knoxcall-key`, or `Unauthorized` when sent as `Authorization: Bearer tk_…` | | Test token | `POST https://sandbox.knoxcall.com/oauth/token` — HTTP Basic `client_id:client_secret`, form `grant_type=client_credentials`. Returns a one-hour `kc_test_…` `access_token` and no refresh token; re-mint on `401 invalid_token` or expiry | | Test Management API | `https://sandbox.knoxcall.com/v1` — `Authorization: Bearer ` | | Live | `POST https://api.knoxcall.com/oauth/token` with a Live key the owner minted, then `https://api.knoxcall.com/v1` with the `kc_live_…` token | | Test proxy host | `https://sandbox-{tenant_slug}.knoxcall.com/api/...` | | Live proxy host | `https://{tenant_slug}.knoxcall.com/api/...` | | Proxy headers | `Authorization: Bearer `, `x-knoxcall-route`, optional `x-knoxcall-environment`. `x-knoxcall-key` is legacy: refused unless the owner turned OAuth-only mode off | | Route create | `POST /v1/routes` with `name`, `target_base_url`, `inject_headers_json` (unknown fields are ignored, not rejected). A new route has `requires_clients: true`; in Test, `PATCH /v1/routes/{id}` with `{"requires_clients": false}` | | SDKs | Node, Python, Go, PHP, Ruby — all export `signup()` and the claim poll (`claimSignup` / `claim_signup` / `ClaimSignup` / `claimSignup`); install commands in [SDKs](/sdks/overview). The Node client takes `clientId` + `clientSecret` and does the token exchange itself | | Machine-readable docs | [https://docs.knoxcall.com/llms.txt](https://docs.knoxcall.com/llms.txt) · OpenAPI: `/api-reference/openapi.yaml` | # Core Concepts Source: https://docs.knoxcall.com/getting-started/core-concepts Understand the three foundational concepts of KnoxCall: Clients, Routes, and Secrets # Core Concepts Before diving into KnoxCall, it's essential to understand the three foundational concepts that power the platform: 1. **Clients** - Who can access your routes 2. **Routes** - Where requests go 3. **Secrets** - How credentials are managed securely Think of these as the building blocks of your API routing infrastructure. *** ## 1. Clients = Authorization (Who Can Access) **Clients** are authorized IP addresses or API keys that can call your routes. ### What is a Client? A client represents a **trusted source** that's allowed to make requests through KnoxCall. This can be: * Your production server's IP address * Your office network IP range * Your development machine * A partner's API server * A mobile app backend ### Why Use Clients? Clients provide **network-level security**: ✅ Only whitelisted IPs can access your routes ✅ Block unauthorized access automatically ✅ Track which systems are making requests ✅ Different clients for dev, staging, and production ### Simple Example ```text theme={"dark"} Your Server (52.123.45.67) → Allowed ✅ Random Internet User → Blocked ❌ ``` ### Key Concept **IP Whitelisting**: KnoxCall checks every incoming request's IP address. If it's not on your authorized client list, the request is rejected before it even reaches your route. *** ## 2. Routes = Forwarding Rules (Where Requests Go) **Routes** define how incoming requests are forwarded to your backend APIs. ### What is a Route? A route is a **smart proxy** that sits between your clients and your backend services. It: * Receives requests from authorized clients * Applies security rules and transformations * Forwards to your backend API * Returns the response ### The Flow ```text theme={"dark"} Client Request ↓ KnoxCall Route (validates, transforms, logs) ↓ Your Backend API ↓ Response back to client ``` ### Why Use Routes? Routes solve common API problems: ✅ **Hide backend URLs** - Clients never know your real endpoints ✅ **Centralize credentials** - API keys injected server-side ✅ **Monitor everything** - All requests logged automatically ✅ **Add security layers** - Rate limiting, IP checks, signatures ✅ **Manage environments** - Dev, staging, prod with one route ### Simple Example **Route name**: `user-api` **Target**: `https://api.example.com` **Methods**: GET, POST When client calls: ```text theme={"dark"} https://YOUR-SUBDOMAIN.knoxcall.com/users/123 ``` KnoxCall forwards to: ```text theme={"dark"} https://api.example.com/users/123 ``` *** ## 3. Secrets = Encrypted Credentials (How Secrets Are Managed) **Secrets** are encrypted credentials (API keys, passwords, tokens) that KnoxCall injects into your requests **server-side**. ### What is a Secret? A secret is a **secure vault entry** containing sensitive data that: * Is encrypted with AES-256-GCM * Never exposed to clients * Injected into requests automatically * Can be rotated without code changes ### Why Use Secrets? Secrets prevent **credential exposure**: ✅ **Never in client code** - API keys stay on KnoxCall's servers ✅ **Encrypted storage** - Military-grade encryption ✅ **Easy rotation** - Update once, affects all routes ✅ **Audit trail** - Track when secrets are used ### The Problem Without Secrets ```javascript theme={"dark"} // ❌ BAD: API key exposed in frontend fetch("https://api.stripe.com/charges", { headers: { "Authorization": "Bearer sk_live_abc123..." } }); ``` Anyone with browser dev tools can see your Stripe key! ### The Solution With Secrets ```javascript theme={"dark"} // ✅ GOOD: No secrets exposed fetch("https://YOUR-SUBDOMAIN.knoxcall.com/api/payments", { headers: { "x-knoxcall-route": "stripe-payments" } }); ``` KnoxCall injects your Stripe key **server-side**. The client never sees it! ### How It Works 1. Store secret in KnoxCall: `stripe_prod_key = sk_live_abc123...` 2. Reference in route config: `{{secret:stripe_prod_key}}` 3. KnoxCall decrypts and injects automatically 4. Backend receives real API key 5. Client never sees plaintext value *** ## How They Work Together These three concepts work in harmony to secure and route your API traffic: ```text theme={"dark"} ┌─────────────────────────────────────────────────────────┐ │ 1. CLIENT │ │ (Authorized IP: 52.123.45.67) │ │ │ │ Makes request with x-knoxcall-route: user-api │ └─────────────────────┬───────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────┐ │ 2. KNOXCALL ROUTE │ │ (user-api) │ │ │ │ • Validates client IP ✓ │ │ • Loads route configuration ✓ │ │ • Decrypts secrets ✓ │ │ • Injects credentials into headers ✓ │ │ • Logs request ✓ │ │ • Forwards to backend ✓ │ └─────────────────────┬───────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────┐ │ 3. YOUR BACKEND API │ │ (https://api.example.com) │ │ │ │ Receives request with: │ │ • Real API credentials (from secrets) │ │ • All original request data │ │ • No knowledge of KnoxCall │ └─────────────────────────────────────────────────────────┘ ``` *** ## Real-World Example Let's see how all three work together in a real scenario: ### Scenario: Mobile App Calling Stripe API **Goal**: Your mobile app needs to charge customers via Stripe, but you can't embed the Stripe API key in the app (users could extract it). **Solution with KnoxCall**: ### Step 1: Create a Client ```text theme={"dark"} Name: production_app_server IP: 52.123.45.67 (your app backend server) Type: Server ``` ### Step 2: Create a Secret ```text theme={"dark"} Name: stripe_prod_key Value: sk_live_EXAMPLE_abc123xyz789... (encrypted) ``` ### Step 3: Create a Route ```text theme={"dark"} Name: stripe-payments Target: https://api.stripe.com Header Injection: Authorization: Bearer {{secret:stripe_prod_key}} Requires Clients: ✓ ``` ### Step 4: Assign Client to Route Connect "production\_app\_server" to "stripe-payments" route. ### Step 5: Make Request Your app backend calls your proxy subdomain. The path after the subdomain is forwarded **verbatim** to the route's target — here `/v1/charges` is appended to `https://api.stripe.com`, so Stripe receives `https://api.stripe.com/v1/charges`. (This `/v1` is Stripe's path, not KnoxCall's own `/v1` platform API prefix.) ```bash theme={"dark"} curl https://YOUR-SUBDOMAIN.knoxcall.com/v1/charges \ -H "x-knoxcall-route: stripe-payments" \ -H "Authorization: Bearer $TOKEN" \ -d "amount=1000" \ -d "currency=usd" ``` `$TOKEN` is a short-lived KnoxCall OAuth token (see [Authentication](/api-reference/authentication)) — or skip token handling entirely with the SDK: `await client.call("stripe-payments", { method: "POST", path: "/v1/charges", body: { … } })`. ### What Happens 1. **Client Check**: KnoxCall sees request from 52.123.45.67 → Authorized ✅ 2. **Route Lookup**: Loads "stripe-payments" route config 3. **Secret Injection**: Decrypts `stripe_prod_key` and adds to headers 4. **Forward**: Sends to Stripe with real API key 5. **Response**: Returns Stripe's response to your app 6. **Logging**: Records everything in audit logs **Result**: Your Stripe API key is never exposed to your mobile app or clients! *** ## Quick Comparison | Concept | Purpose | Example | | - | - | - | | **Client** | Who can access | `52.123.45.67` (your server's IP) | | **Route** | Where to forward | `stripe-payments` → `https://api.stripe.com` | | **Secret** | Credentials to inject | `stripe_prod_key` = `sk_live_abc123...` | *** ## Next Steps Now that you understand the core concepts, you're ready to start building: Follow the step-by-step tutorial Set up your first API route Authorize an IP address Store credentials securely See real-world route patterns Let AI configure routes for you *** ## Common Questions ### Do I need all three? **For basic usage**: You need at least a **Route**. Clients and Secrets are optional but highly recommended for security. **For production**: You should use all three for maximum security and flexibility. ### Can I have multiple clients per route? **Yes!** You can assign multiple clients to a single route. For example, both your production server and staging server can access the same route. ### Can I use multiple secrets in one route? **Yes!** You can inject multiple secrets into headers and body. For example: ```json theme={"dark"} { "Authorization": "Bearer {{secret:stripe_key}}", "X-API-Key": "{{secret:sendgrid_key}}" } ``` ### What if my IP changes frequently? Both client types (**User** and **Server**) are IP-allowlist identities — each client is pinned to one or more IP addresses, so a changing IP would normally be rejected. For development machines where the IP changes regularly, use a **test API key**, which skips the client IP check entirely. (API keys are managed separately and are not tied to a client type.) *** **Remember**: Clients = Who, Routes = Where, Secrets = How. Master these three, and you've mastered KnoxCall! # Creating Your First Client Source: https://docs.knoxcall.com/getting-started/first-client Secure your routes with IP whitelisting. Learn how to create clients and assign them to routes. # Creating Your First Client Add IP-based access control to your routes by creating and assigning clients. ## What is a Client? A **client** in KnoxCall represents an **authorized IP address** or IP range that can access your routes. This provides network-level security in addition to API key authentication. **Important**: Clients are NOT the same as your API credentials! * **Credentials** (OAuth tokens or API keys) = Authentication (who you are) * **Clients** = Authorization (where you're calling from) Both are checked for secure routes. ## Why Use Clients? **Without clients** (client IP authorization disabled): * Anyone with your API key can call your routes from anywhere * Higher risk if API key is leaked **With clients** (client IP authorization enabled): * Only whitelisted IPs can call routes, even with a valid API key * Defense-in-depth security model * Useful for server-to-server integrations ## Client Types ### Server Clients **Use case**: Backend servers with static IPs **Examples**: * Your production app server (`52.123.45.67`) * Office network (`192.168.1.0/24`) * Cloud function with fixed egress IP **IP Format**: Single IP or CIDR range ```text theme={"dark"} 52.123.45.67 192.168.1.0/24 10.0.0.0/16 ``` ### User Clients **Use case**: Development machines, mobile devices **Examples**: * Your laptop for testing * Team member workstations * VPN exit nodes **IP Format**: Usually single IPs ```text theme={"dark"} 203.45.67.89 ``` User IPs often change (mobile networks, home ISPs). For production, prefer server clients with static IPs. ## Create a Client ### Step 1: Find Your IP Address First, determine what IP address to whitelist: **Your current IP:** ```bash theme={"dark"} curl https://api.ipify.org ``` **Your server's IP:** ```bash theme={"dark"} curl https://api.ipify.org # Run this command on your server ``` **IP range (CIDR):** Use a calculator like [https://www.ipaddressguide.com/cidr](https://www.ipaddressguide.com/cidr) ### Step 2: Navigate to Clients 1. Open the **Proxy** section in the sidebar 2. Select **Clients** 3. Click **Add Client** ### Step 3: Fill in Client Details **Client Name:** ```text theme={"dark"} Production App Server ``` *Descriptive name for this IP/range* **Type:** Select one * **Server**: Backend servers, static IPs * **User**: Development machines, laptops **IP Address:** ```text theme={"dark"} 52.123.45.67 ``` *Or CIDR range like `192.168.1.0/24`* **Multiple IPs** (comma-separated): ```text theme={"dark"} 52.123.45.67,52.123.45.68,52.123.45.69 ``` **Description** (optional): ```text theme={"dark"} AWS EC2 production instance in us-east-1 ``` **Enabled:** ✓ ON ### Step 4: Save Click **Create Client** Your client is created but NOT yet assigned to any routes. ## Assign Client to a Route Clients must be assigned to specific routes and environments to grant access. ### Step 1: Open Your Route 1. Navigate to **Routes** 2. Click your route (e.g., "my-first-route") ### Step 2: Go to Clients Tab Click the **Clients** tab This shows which clients are allowed for each environment. ### Step 3: Assign Client 1. Find the environment (e.g., "production") 2. Click **Assign Clients** 3. Select your client from the list 4. Click **Save** Now requests from that IP can access this route (in this environment)! ## Test Client Access In the examples below, `$TOKEN` is a short-lived KnoxCall OAuth token (see [Authentication](/api-reference/authentication)). ### From Authorized IP ```bash theme={"dark"} # Should work (200 OK) curl -X GET "https://a1b2c3d4.DunderMifflin.knoxcall.com/posts/1" \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: my-first-route" ``` ### From Unauthorized IP ```bash theme={"dark"} # Should fail (403 Forbidden) # Try from different machine/network curl -X GET "https://a1b2c3d4.DunderMifflin.knoxcall.com/posts/1" \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: my-first-route" # Response: # {"error": "IP not authorized"} ``` ## Client Connectivity Check KnoxCall can ping your server clients to verify they're online. ### Run Connectivity Test 1. Go to **Clients** page 2. Find your server client 3. Click **Ping** button **Online**: Green checkmark ✅ **Offline**: Red X ❌ This is useful for monitoring server health. Ping requires ICMP to be allowed. If your server blocks ICMP, the ping will fail even if the server is online. ## Advanced: CIDR Ranges CIDR notation allows you to whitelist entire subnets. **Common ranges:** ```text theme={"dark"} 192.168.1.0/24 → 192.168.1.0 - 192.168.1.255 (256 IPs) 10.0.0.0/16 → 10.0.0.0 - 10.0.255.255 (65,536 IPs) 172.16.0.0/12 → 172.16.0.0 - 172.31.255.255 (1,048,576 IPs) ``` **Use cases:** * Office network: `192.168.1.0/24` * AWS VPC: `10.0.0.0/16` * Cloud provider IP range: Specific to provider **Calculator:** [https://www.ipaddressguide.com/cidr](https://www.ipaddressguide.com/cidr) ## Disable Client IP Authorization To allow requests from any IP (with valid API key only): 1. Edit your route 2. Find **Requires Clients** toggle 3. Turn it OFF 4. Save Only disable for public routes or testing. Production routes should use client IP authorization. ## Multiple Clients per Route You can assign multiple clients to a route: **Example scenario:** * `office-network` → `192.168.1.0/24` * `aws-prod-server-1` → `52.123.45.67` * `aws-prod-server-2` → `52.123.45.68` All three can access the route. ## Environment-Specific Clients Clients are assigned **per environment**. This lets you use different IPs for dev/staging/production: **Development environment:** * `dev-laptop` → `203.45.67.89` **Staging environment:** * `staging-server` → `34.56.78.90` **Production environment:** * `prod-server-1` → `52.123.45.67` * `prod-server-2` → `52.123.45.68` Same route, different allowed IPs per environment! ## Common Issues ### "IP not authorized" Error **Causes:** 1. Your IP isn't whitelisted 2. Client not assigned to route environment 3. Client is disabled 4. Your IP changed (mobile/home networks) 5. Request is going through proxy/CDN with different IP **Debug:** 1. Check actual IP: `curl https://api.ipify.org` 2. Compare with whitelisted IP in client settings 3. Verify client is assigned to route + environment 4. Check client is enabled 5. Look at request logs to see incoming IP ### Client Shows Offline **Causes:** * ICMP (ping) blocked by firewall * Server is actually down * Network routing issues **Not a blocker**: Routes still work if connectivity check fails ### Multiple IPs Needed **Solution**: Use CIDR ranges or comma-separated IPs ```text theme={"dark"} 52.123.45.67,52.123.45.68,52.123.45.69 ``` Or: ```text theme={"dark"} 52.123.45.64/28 (includes .65 through .78) ``` ### IP Changes Frequently **For development:** * Use test API keys (skip IP check) * Or temporarily disable `requires_clients` **For production:** * Use static IPs (elastic IPs on AWS, etc.) * Or use VPN with fixed egress IP * Or use cloud provider IP ranges ## Security Best Practices ✅ **Do:** * Use static IPs for production clients * Combine with API key authentication * Use CIDR ranges for office networks * Enable connectivity monitoring * Document client purpose in description ❌ **Don't:** * Use `0.0.0.0/0` (allows all IPs - defeats the purpose) * Add untrusted IPs to production routes * Forget to remove old/unused clients * Share clients across unrelated routes ## Next Steps Now that you understand clients: Assign different clients per environment Add HMAC signatures for extra security Add encrypted credentials to requests Limit requests per client ## Related Concepts * **API Credentials** (OAuth tokens or API keys): Authentication layer (who you are) * **Clients**: Authorization layer (where you're calling from) * **Environments**: Different client sets for dev/staging/prod * **Request Signing**: Additional verification via HMAC signatures *** * **Level**: beginner * **Time**: 5 minutes `clients`, `ip-whitelist`, `security`, `authorization` # Creating Your First Environment Source: https://docs.knoxcall.com/getting-started/first-environment Set up dev, staging, and production environments. Route the same API to different backends based on environment. # Creating Your First Environment Set up development, staging, and production environments to safely test changes before deploying to production. ## What is an Environment? An **environment** in KnoxCall allows you to use the **same route** with **different configurations** for dev, staging, and production. **Same route, different configurations per environment:** | Aspect | Development | Staging | Production | | - | - | - | - | | **Target URL** | `http://localhost:3000` | `https://staging-api.example.com` | `https://api.example.com` | | **Secrets** | `dev_api_key` | `staging_api_key` | `prod_api_key` | | **Allowed IPs** | Your laptop | Staging server | Production servers | | **Rate Limits** | Unlimited | 1000/min | 100/min | ## Why Use Environments? ✅ **Test safely**: Try changes in dev/staging before production ✅ **Prevent mistakes**: Can't accidentally break production ✅ **Easy switching**: Change environment with one header ✅ **Different security**: Relaxed limits for dev, strict for prod ✅ **Separate credentials**: Dev keys for dev, prod keys for prod ## Understanding Environment Overrides KnoxCall uses a **base + override** system: 1. **Base route**: Default configuration (usually production) 2. **Environment overrides**: Environment-specific changes **Override pattern**: `NULL = inherit from base` **Example:** ```text theme={"dark"} Base Route (production): target_url: https://api.production.com inject_headers: {"X-API-Key": "{{secret:prod_key}}"} rate_limit: 100/min Development Override: target_url: http://localhost:3000 (overridden) inject_headers: NULL (inherited from base) rate_limit: NULL (inherited from base) Staging Override: target_url: https://api.staging.com (overridden) inject_headers: {"X-API-Key": "{{secret:staging_key}}"} (overridden) rate_limit: 1000/min (overridden) ``` ## Create Environments By default, KnoxCall creates a **"production"** environment for you. Let's add development and staging. ### Step 1: Navigate to Environments 1. Open the **Proxy** section in the sidebar 2. Select **Environments** 3. You'll see "production" already exists ### Step 2: Create Development Environment Click **Add Environment** **Environment Name:** ```text theme={"dark"} development ``` *Lowercase, no spaces. Used in API requests.* **Display Name:** ```text theme={"dark"} Development ``` *Friendly name shown in UI* **Description** (optional): ```text theme={"dark"} Local development servers and testing ``` **Color:** Choose a color * 🟢 Green (`#00ff88`) for development **Set as Default:** ❌ NO *Leave production as default* Click **Create** ### Step 3: Create Staging Environment Click **Add Environment** again **Environment Name:** ```text theme={"dark"} staging ``` **Display Name:** ```text theme={"dark"} Staging ``` **Description:** ```text theme={"dark"} Pre-production testing environment ``` **Color:** * 🟡 Yellow (`#ffaa00`) for staging **Set as Default:** ❌ NO Click **Create** Now you have three environments: development, staging, production! ## Configure Route for Environments Now configure your route to use different URLs per environment. ### Step 1: Edit Your Route 1. Navigate to **Routes** 2. Click your route (e.g., "my-first-route") 3. Click **Edit** ### Step 2: Set Base Configuration (Production) This is your **base environment** (production): **Target Base URL:** ```text theme={"dark"} https://api.production.com ``` **Inject Headers:** ```json theme={"dark"} { "Authorization": "Bearer {{secret:prod_api_key}}" } ``` **Base Environment:** ```text theme={"dark"} production ``` Save the base configuration. ### Step 3: Add Environment Overrides Now add overrides for development and staging. #### Development Override 1. Click **Environment** tab 2. Select **development** environment 3. Configure: **Target Base URL:** ```text theme={"dark"} http://localhost:3000 ``` *Override: Route to local dev server* **Inject Headers:** Leave NULL *Inherit from base (uses same header template)* **Rate Limit:** Leave NULL *Inherit from base* **Require Signature:** Leave NULL *Inherit from base* Save development override. #### Staging Override 1. Select **staging** environment 2. Configure: **Target Base URL:** ```text theme={"dark"} https://staging-api.production.com ``` *Override: Route to staging server* **Inject Headers:** ```json theme={"dark"} { "Authorization": "Bearer {{secret:staging_api_key}}" } ``` *Override: Use staging API key instead of prod* **Rate Limit:** Leave NULL or set different limit *Can be inherited or overridden* Save staging override. ### Step 4: Assign Environment-Specific Clients Now assign different IP addresses per environment: #### Development 1. Go to **Clients** tab 2. Select "development" 3. Assign your laptop/dev machine IP 4. Example: `dev-laptop` (203.45.67.89) #### Staging 1. Select "staging" 2. Assign staging server IPs 3. Example: `staging-server` (34.56.78.90) #### Production 1. Select "production" 2. Assign production server IPs 3. Example: `prod-server-1`, `prod-server-2` ## Use Environments in Requests With the SDK, pass `environment` on the call; with raw curl, specify the `x-knoxcall-environment` header: ```typescript theme={"dark"} const resp = await client.call("my-route", { path: "/api/users", environment: "development", }); ``` In the curl examples below, `$TOKEN` is a short-lived KnoxCall OAuth token — mint it once as shown in [Authentication](/api-reference/authentication). ### Development Request ```bash theme={"dark"} curl -X GET "https://a1b2c3d4.DunderMifflin.knoxcall.com/api/users" \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: my-route" \ -H "x-knoxcall-environment: development" ``` This routes to: `http://localhost:3000/api/users` ### Staging Request ```bash theme={"dark"} curl -X GET "https://a1b2c3d4.DunderMifflin.knoxcall.com/api/users" \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: my-route" \ -H "x-knoxcall-environment: staging" ``` This routes to: `https://staging-api.production.com/api/users` ### Production Request (Default) ```bash theme={"dark"} curl -X GET "https://a1b2c3d4.DunderMifflin.knoxcall.com/api/users" \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: my-route" ``` No environment header = uses base environment (production) This routes to: `https://api.production.com/api/users` ## JavaScript Examples ### With Environment Switching ```javascript theme={"dark"} import { KnoxCall } from "@knoxcall/sdk"; const ENVIRONMENT = process.env.NODE_ENV; // 'development', 'staging', 'production' const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET // Only pass an environment for non-production (no environment = base environment) const resp = await client.call("my-route", { path: "/api/users", ...(ENVIRONMENT !== "production" && { environment: ENVIRONMENT }), }); console.log(await resp.json()); ``` ### Environment-Specific Logic Bind the route once per environment with `client.route()`, then use plain HTTP verbs: ```javascript theme={"dark"} const devApi = client.route("my-route", { environment: "development" }); const prodApi = client.route("my-route"); // no environment = base environment (production) const devUsers = await (await devApi.get("/api/users")).json(); // → http://localhost:3000/api/users const prodUsers = await (await prodApi.get("/api/users")).json(); // → https://api.production.com/api/users ``` ## View Environment Activity ### In Request Logs 1. Navigate to **Logs** → **API Logs** 2. See **Environment** badge on each request 3. Filter by environment ### In Analytics 1. Navigate to **Analytics** 2. Group by environment 3. Compare traffic across dev/staging/prod ## Best Practices ### Environment Naming **Recommended names:** * `development` (or `dev`) * `staging` (or `stage`) * `production` (or `prod`) **Avoid:** * Capital letters * Spaces * Special characters (except hyphens) ### Default Environment **Always set production as default!** If clients forget the environment header, they should hit production (not dev/staging). This prevents accidental dev traffic in production apps. ### Environment Colors Use colors for quick visual identification: * 🔴 **Production**: Red (`#ff0055`) - be careful! * 🟡 **Staging**: Yellow (`#ffaa00`) - caution * 🟢 **Development**: Green (`#00ff88`) - safe to experiment ### Separate Secrets Per Environment **Don't** use the same API keys for dev and production! **Do** create environment-specific secrets: * `stripe_dev_key` * `stripe_staging_key` * `stripe_prod_key` ### Different Rate Limits **Development:** Unlimited (or very high) * Fast iteration, no throttling during testing **Staging:** Moderate (1000/min) * Test rate limiting behavior **Production:** Strict (100/min) * Protect your backend ## Common Issues ### Environment Not Found **Error:** `Environment 'staging' not found` **Causes:** * Typo in environment name * Environment doesn't exist * Environment name is case-sensitive **Fix:** * Check environment name spelling * Verify environment exists in Environments page * Use exact lowercase name ### Wrong Backend Reached **Symptoms:** Development request hits production API **Causes:** * Missing `x-knoxcall-environment` header * Typo in header value * Environment override not configured **Fix:** * Add header: `x-knoxcall-environment: development` * Check header spelling (lowercase) * Verify environment override has correct target URL ### "IP not authorized" in New Environment **Cause:** You created a new environment but didn't assign clients to it **Fix:** 1. Go to route → **Clients** tab 2. Select the new environment 3. Assign clients (IP addresses) 4. Save ### NULL vs Empty String **NULL (not set):** Inherits from base **Empty string `""`:** Overrides with empty value **Example:** ```text theme={"dark"} Base: inject_headers = {"X-Key": "abc"} Override with NULL: inject_headers = NULL → Result: {"X-Key": "abc"} (inherited) Override with {}: inject_headers = {} → Result: {} (no headers injected) ``` ## Advanced: Temporary Environments For large features or hotfixes, create temporary environments: **Feature branch:** ```text theme={"dark"} feature-new-dashboard ``` **Hotfix:** ```text theme={"dark"} hotfix-critical-bug ``` **After merging:** Delete the environment (no longer needed) ## Next Steps Now that you have environments set up: Learn safe deployment strategies Create environment-specific secrets Assign different IPs per environment Configure different limits per environment ## Related Concepts * **Base Route**: Default configuration (usually production) * **Environment Override**: Environment-specific changes (NULL = inherit) * **Default Environment**: Used when no header specified * **Per-environment clients**: IP whitelist per environment *** * **Level**: beginner * **Time**: 10 minutes `environments`, `configuration`, `deployment`, `quickstart` # Creating Your First Route Source: https://docs.knoxcall.com/getting-started/first-route Create your first API route in KnoxCall. Learn how routes work and how to proxy requests to your backend. # Creating Your First Route Get your first webhook route up and running. A route forwards incoming requests from your clients to your backend API with added security and monitoring. ## What is a Route? A **route** is a forwarding configuration that tells KnoxCall where to send incoming requests. Think of it as a smart proxy that: * ✅ Validates API keys and IP addresses * ✅ Injects secrets (API keys, OAuth tokens) into requests * ✅ Logs all requests and responses * ✅ Applies rate limiting * ✅ Supports multiple environments (dev, staging, production) ## How Routes Work Here's the request flow: ```text theme={"dark"} Client Request ↓ POST https://a1b2c3d4.DunderMifflin.knoxcall.com/webhooks/order Headers: x-knoxcall-route: shopify-orders Authorization: Bearer kc_... (OAuth token auth) OR your IP is whitelisted (for IP-based auth) ↓ KnoxCall Route Selection: - Subdomain identifies your TENANT (a1b2c3d4.DunderMifflin) - x-knoxcall-route header selects the ROUTE ("shopify-orders") ↓ Validates access token OR IP address ✓ Checks client authorization ✓ Injects secrets: {{secret:shopify_api_key}} ↓ Forwards to: https://api.yourbackend.com/webhooks/order ↓ Response → Back to client ``` **Important**: * **Subdomain** determines your tenant * **`x-knoxcall-route` header** determines which route to use * The URL path (`/webhooks/order`) is passed through to your backend unchanged ## Create Your First Route ### Step 1: Open Routes Page 1. Navigate to **Routes** in the sidebar 2. Click **Add Route** ### Step 2: Configure Basic Settings **Route Name:** ```text theme={"dark"} my-first-route ``` *Lowercase, use hyphens. This name is used in the `x-knoxcall-route` header to identify which route to use.* **Target Base URL:** ```text theme={"dark"} https://jsonplaceholder.typicode.com ``` *Your backend API's base URL. We'll use a test API for this example.* **Base Environment:** ```text theme={"dark"} production ``` *Default environment (already created for you)* ### Step 3: Security Settings (Optional for Now) You can leave these as defaults: * **Requires Clients**: ON (IP whitelist required) * **Require Signature**: OFF * **Rate Limiting**: OFF We'll cover these in later guides. ### Step 4: Save Click **Create Route** - your route is now live! ## Test Your Route ### Prerequisites You need: 1. Your tenant subdomain (e.g., `a1b2c3d4.DunderMifflin.knoxcall.com`) — the SDK discovers this automatically 2. To be signed in: run `knoxcall login` once (or [create an OAuth client](/api-reference/authentication#create-an-oauth-client) under **Settings** → **API** → **OAuth Clients** and set `KNOXCALL_CLIENT_ID` + `KNOXCALL_CLIENT_SECRET`) ### Make a Request **Option 1: Using the SDK** (recommended) ```typescript theme={"dark"} import { KnoxCall } from "@knoxcall/sdk"; const client = new KnoxCall(); // credentials from `knoxcall login` const resp = await client.call("my-first-route", { path: "/posts/1" }); console.log(await resp.json()); ``` **Option 2: Using curl with an OAuth token** ```bash theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) curl -X GET "https://a1b2c3d4.DunderMifflin.knoxcall.com/posts/1" \ -H "x-knoxcall-route: my-first-route" \ -H "Authorization: Bearer $TOKEN" ``` **Option 3: Using IP-Based Authentication** (if your IP is whitelisted) ```bash theme={"dark"} curl -X GET "https://a1b2c3d4.DunderMifflin.knoxcall.com/posts/1" \ -H "x-knoxcall-route: my-first-route" ``` **Required Headers:** * `x-knoxcall-route`: The route name you created (REQUIRED) * `Authorization: Bearer `: Your OAuth token (if not using IP-based auth). Legacy API keys go in `x-knoxcall-key` instead — never as a Bearer header on the proxy. **URL Structure:** * `https://a1b2c3d4.DunderMifflin.knoxcall.com` → Your unique tenant subdomain * `/posts/1` → Path passed through to your backend * Subdomain identifies your tenant, header identifies your route ### Expected Response You should see JSON data from the test API: ```json theme={"dark"} { "userId": 1, "id": 1, "title": "sunt aut facere...", "body": "quia et suscipit..." } ``` This means your route is working! 🎉 ## Understanding URL Mapping KnoxCall forwards the entire path to your backend: | Client Request | Target Base URL | Forwarded To | | - | - | - | | `/posts/1` | `https://api.example.com` | `https://api.example.com/posts/1` | | `/api/v2/users` | `https://backend.com` | `https://backend.com/api/v2/users` | | `/webhooks/stripe?id=123` | `https://app.com` | `https://app.com/webhooks/stripe?id=123` | **Query parameters** are also passed through unchanged. ## View Request Logs 1. Navigate to **Logs** → **API Logs** 2. Find your request 3. Click to see full details: * Request headers and body * Response headers and body * Response time * Status code * Source IP and geo-location ## Common Configuration Options ### Inject Headers Add custom headers to every request: ```json theme={"dark"} { "X-API-Key": "{{secret:my_api_key}}", "X-Custom-Header": "my-value" } ``` Use `{{secret:name}}` to inject encrypted secrets from your vault. ### Inject Body Fields Add fields to JSON request bodies: ```json theme={"dark"} { "api_key": "{{secret:printnode_key}}", "metadata": { "source": "knoxcall" } } ``` ### Method-Specific Configuration Configure different headers per HTTP method: * **POST requests**: Inject write API key * **GET requests**: Inject read-only API key * **DELETE requests**: Require admin API key This is configured in the **Method Configs** section. ## Security: Client IP Authorization By default, `requires_clients: true` means only whitelisted IP addresses can use this route. **To allow your IP:** 1. On your route, open the **Clients** tab and click **Add Client** 2. Pick an existing client from the list — or click **New client** (it is the first row, and the only button when you have no clients yet) 3. On the client form, enter: * Name: "My Office" * Type: "server" * IP Address: Your IP (or CIDR range like `192.168.1.0/24`) 4. Click **Create Client** — you are taken straight back to your route with the new client selected Now requests from your IP will work! Your route form is kept while you create the client. The same goes for a new client or environment form: leave the page and come back, and what you had typed is restored (with a **Discard draft** link if you would rather start again). Drafts live only in the current browser tab and are dropped when it closes. A draft never keeps a credential: a header value typed in the clear (rather than as a `{{secret:...}}` reference) and anything in the target URL's query string are dropped, and the restored form tells you what to enter again. **To disable IP authorization** (for testing): * Edit your route * Toggle **Requires Clients** to OFF * Save Disabling client IP authorization means anyone with a valid credential (OAuth token or API key) can call the route. Only do this for public routes. ## Common Issues ### "Route not found" With a valid credential for your workspace, a Route that does not resolve answers `404` with a JSON envelope naming the reason — `route_not_found` (the identifier you sent is echoed back), `environment_not_configured` or `environment_disabled` (the environment is named) — and `X-Knox-Origin: knoxcall` with no `X-Knox-Upstream-Status`, so you can tell it from your provider's own 404s. Without a valid credential the same request answers the opaque `401 Unauthorized` below, on purpose: route names are not enumerable by anyone your workspace has not authenticated. **Check:** * The `x-knoxcall-route` header value matches your route's slug, name or UUID exactly (case-sensitive) * The route is enabled (toggle in route list), and the environment you named (or your default environment) is configured and enabled on it * You're using the correct tenant subdomain — and, if you got a `401` rather than a `404`, that your credential belongs to this workspace ### "IP not authorized" **Fix:** * Add your IP address as a client (see "Client IP Authorization" above) * Or disable `requires_clients` temporarily for testing ### "Invalid API key" / authentication errors **Check:** * Your OAuth token hasn't expired (tokens last 1 hour — the SDK refreshes automatically; re-mint for curl) * Legacy keys only: you copied the full key (the complete `tk_..._...` string, not just the prefix shown in the dashboard) and it hasn't been deleted * You're using the correct tenant subdomain ### "502 Bad Gateway" **Causes:** * Your target URL is unreachable from the internet * The backend server is down * DNS resolution failed for the target domain **Debug:** * Test the target URL directly: `curl https://your-backend.com/endpoint` * Check your backend server logs * Verify the target URL in route settings ### "Connection timeout" **Causes:** * Your backend is slow to respond (> 30 seconds default timeout) * Network connectivity issues * Backend server overloaded **Fix:** * Increase timeout in route settings * Optimize your backend response time * Check backend server resources ## Next Steps Now that you have a working route: Whitelist IP addresses with clients Set up dev, staging, production Add encrypted API keys to requests Protect your backend from abuse ## Advanced Topics * **[AI Route Setup](/advanced/ai-route-setup)**: Let AI configure routes from code or cURL commands * **[Route Examples](/essentials/routes/route-examples)**: Real-world patterns for Stripe, Twilio, etc. * **Request Signing**: HMAC-SHA256 signatures to prevent replay attacks * **[Workflow Automation](/workflows/workflows-overview)**: Trigger workflows on route events * **[Webhooks](/webhooks/webhooks-overview)**: Get notified of successes and failures *** * **Level**: beginner * **Time**: 5 minutes `routes`, `quickstart`, `beginner`, `proxy` # Creating Your First Secret Source: https://docs.knoxcall.com/getting-started/first-secret Store API keys and credentials securely with encrypted secrets. Learn how to inject secrets into requests server-side. # Creating Your First Secret Learn how to store sensitive credentials securely and inject them into backend requests without exposing them to clients. ## What is a Secret? A **secret** in KnoxCall is an **encrypted credential** (API key, password, token) that's injected into your backend requests **server-side**. Your clients never see the actual value. ### Why Use Secrets? **Without KnoxCall secrets:** ```javascript theme={"dark"} // ❌ Frontend code - API key exposed in browser! fetch("https://api.stripe.com/charges", { headers: { "Authorization": "Bearer sk_live_EXAMPLE_abc123xyz789..." } }); ``` Anyone with dev tools can see your Stripe key! 😱 **With KnoxCall secrets:** ```javascript theme={"dark"} // ✅ Frontend code - No secrets exposed fetch("https://a1b2c3d4.DunderMifflin.knoxcall.com/api/payments", { headers: { "Authorization": `Bearer ${knoxcallToken}`, // short-lived OAuth token (kc_…) "x-knoxcall-route": "stripe-payments" } }); ``` KnoxCall injects your Stripe key server-side: ```text theme={"dark"} Client Request → KnoxCall → Adds "Authorization: Bearer sk_live_abc..." → Stripe API ``` Your secret stays secure on KnoxCall's servers! 🔐 ## Secret Types KnoxCall supports three types of secrets: ### 1. API Key / String (Most Common) Simple encrypted values: * API keys (Stripe, SendGrid, Twilio) * Passwords (database, services) * Tokens (auth tokens, webhook secrets) * Any sensitive string value ### 2. OAuth2 (Advanced) Full OAuth2 flow with automatic token refresh: * Google OAuth tokens (auto-refreshes when expired) * Shopify, HubSpot, Microsoft, Slack OAuth * Stores access token, refresh token, and expiry * Handles token rotation automatically ### 3. Certificate SSL/TLS certificates and private keys: * SSL/TLS certificates for mutual TLS (mTLS) authentication * Private keys for signing and encryption * Client certificates for API authentication We'll focus on **string secrets** in this guide. ## How Secrets Work ```text theme={"dark"} 1. You create secret in KnoxCall UI ↓ 2. Value encrypted with AES-256-GCM ↓ 3. Stored securely in encrypted vault ↓ 4. You reference secret in route config: {{secret:stripe_key}} ↓ 5. Client makes request through KnoxCall ↓ 6. KnoxCall decrypts secret server-side ↓ 7. Injects decrypted value into backend request ↓ 8. Backend receives request with actual API key ↓ 9. Client never sees the secret value! ``` **Encryption:** AES-256-GCM with envelope encryption (same security as AWS, Google Cloud) ## Create Your First Secret ### Step 1: Navigate to Secrets 1. Open the **Protect** section in the sidebar 2. Select **Secrets** 3. Click **Add Secret** ### Step 2: Choose Secret Type Select **String Secret** (for this guide) ### Step 3: Fill in Details **Secret Name:** ```text theme={"dark"} stripe_prod_key ``` *Lowercase with underscores. This is how you'll reference it in templates.* **Secret Value:** ```text theme={"dark"} sk_live_EXAMPLE_abc123xyz789 ``` *Paste your actual Stripe API key* ### Step 4: Save Click **Create Secret** The value is immediately encrypted using AES-256-GCM. You'll never see the plaintext value again in the UI! **Copy your secret value elsewhere if you need it!** Once saved, KnoxCall only shows encrypted form. The plaintext cannot be retrieved (only used in requests). ## Use Secret in a Route Now let's inject your secret into backend requests. ### Step 1: Edit Your Route 1. Navigate to **Routes** 2. Click your route (e.g., "stripe-payments") 3. Click **Edit** ### Step 2: Inject Secret in Headers Find the **Inject Headers** section and add: ```json theme={"dark"} { "Authorization": "Bearer {{secret:stripe_prod_key}}" } ``` **Template syntax:** `{{secret:name}}` * `secret:` is required prefix * `name` is your secret name (e.g., `stripe_prod_key`) **Common header patterns:** **Bearer token:** ```json theme={"dark"} { "Authorization": "Bearer {{secret:api_key}}" } ``` **API key header:** ```json theme={"dark"} { "X-API-Key": "{{secret:sendgrid_key}}" } ``` **Basic auth:** ```json theme={"dark"} { "Authorization": "Basic {{secret:base64_credentials}}" } ``` **Custom header:** ```json theme={"dark"} { "X-Webhook-Secret": "{{secret:webhook_secret}}", "X-App-ID": "{{secret:app_id}}" } ``` ### Step 3: Or Inject Secret in Body For POST/PUT/PATCH requests, inject into JSON body: ```json theme={"dark"} { "api_key": "{{secret:printnode_key}}", "computerId": 123, "options": { "auth_token": "{{secret:auth_token}}" } } ``` Secrets work in nested objects too! ### Step 4: Save Route Click **Save Changes** ## Test Your Secret ### Make a Request With the SDK: ```typescript theme={"dark"} const resp = await client.call("stripe-payments", { method: "POST", path: "/api/charge", body: { amount: 1000, currency: "usd" }, }); ``` Or with curl, using a short-lived OAuth token (see [Authentication](/api-reference/authentication)): ```bash theme={"dark"} curl -X POST "https://a1b2c3d4.DunderMifflin.knoxcall.com/api/charge" \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: stripe-payments" \ -H "Content-Type: application/json" \ -d '{"amount": 1000, "currency": "usd"}' ``` ### What Happens 1. KnoxCall receives your request 2. Loads route "stripe-payments" 3. Sees `{{secret:stripe_prod_key}}` in header template 4. Decrypts secret server-side 5. Replaces template with actual value: ```text theme={"dark"} Authorization: Bearer sk_live_EXAMPLE_abc123xyz789... ``` 6. Forwards request to Stripe API with real key 7. Returns response to you **Your client never saw the Stripe key!** ✅ ### View in Logs 1. Navigate to **Logs** → **API Logs** 2. Find your request 3. Click to expand details 4. Notice: Request headers show `{{secret:stripe_prod_key}}` (template) 5. But backend received actual decrypted value Logs never expose plaintext secret values for security. ## Multiple Secrets in One Route You can use multiple secrets in a single route: ```json theme={"dark"} { "Authorization": "Bearer {{secret:stripe_key}}", "X-Webhook-Secret": "{{secret:webhook_secret}}", "X-User-ID": "{{secret:user_id}}" } ``` Or in body: ```json theme={"dark"} { "payment_key": "{{secret:stripe_key}}", "notification_key": "{{secret:twilio_key}}", "database_url": "{{secret:postgres_url}}" } ``` All secrets are decrypted and injected in a single pass. ## Environment-Specific Secrets Use different secrets per environment (dev/staging/prod): ### Create Environment-Specific Secrets 1. **Development:** * Name: `stripe_dev_key` * Value: `sk_test_abc123...` 2. **Staging:** * Name: `stripe_staging_key` * Value: `sk_test_xyz789...` 3. **Production:** * Name: `stripe_prod_key` * Value: `sk_live_abc123...` ### Configure Route Per Environment **Base route (production):** ```json theme={"dark"} { "Authorization": "Bearer {{secret:stripe_prod_key}}" } ``` **Development override:** ```json theme={"dark"} { "Authorization": "Bearer {{secret:stripe_dev_key}}" } ``` **Staging override:** ```json theme={"dark"} { "Authorization": "Bearer {{secret:stripe_staging_key}}" } ``` Now each environment uses its own Stripe key automatically! ## Rotating a Secret Updating a secret's value rotates it in place across every route that references it. 1. Navigate to **Secrets** 2. Click your secret (e.g., "stripe\_prod\_key") 3. In the **Details** tab, edit the secret value 4. Enter the new value: ``` sk_live_EXAMPLE_new789xyz123 ``` 5. Click **Save** **What happens:** * The encrypted value is replaced with the new one * The new value takes effect immediately * All routes using this secret now use the new value * Zero downtime! No code changes needed ## Common Use Cases ### Payment APIs ```json theme={"dark"} { "Authorization": "Bearer {{secret:stripe_prod_key}}" } ``` ```json theme={"dark"} { "Authorization": "Bearer {{secret:paypal_access_token}}" } ``` ### Email/SMS Services ```json theme={"dark"} { "Authorization": "Bearer {{secret:sendgrid_api_key}}" } ``` ```json theme={"dark"} { "X-API-Key": "{{secret:twilio_auth_token}}" } ``` ### Webhooks ```json theme={"dark"} { "X-Webhook-Secret": "{{secret:github_webhook_secret}}" } ``` ```json theme={"dark"} { "X-Signature": "{{secret:shopify_hmac_secret}}" } ``` ### Database Credentials ```json theme={"dark"} { "db_url": "{{secret:postgres_connection_string}}" } ``` ### Printing Services ```json theme={"dark"} { "Authorization": "Basic {{secret:printnode_api_key}}" } ``` ## Security Best Practices ### Naming Conventions ✅ **Good names:** ```text theme={"dark"} stripe_prod_key sendgrid_dev_api_key database_password_production github_webhook_secret_staging ``` ❌ **Bad names:** ```text theme={"dark"} secret1 key password temp ``` Use descriptive names that indicate: * Service (stripe, sendgrid) * Purpose (api\_key, webhook\_secret) * Environment (prod, staging, dev) ### Secret Rotation **Recommended rotation schedule:** * **Critical secrets** (payment APIs): Every 90 days * **Less critical**: Every 180 days * **Development**: Annually **How to rotate:** 1. Generate new key in third-party service (Stripe, etc.) 2. Create new version in KnoxCall 3. Test in staging first 4. Deploy to production 5. Wait 24-48 hours 6. Revoke old key in third-party service ### Separation by Environment ✅ **Do:** * Different secrets for dev/staging/prod * Never use prod secrets in development * Test secret rotation in staging first ❌ **Don't:** * Reuse production secrets in development * Share secrets across unrelated services * Commit secrets to git (even encrypted) ### Access Control * Only give team members access to secrets they need * Use least privilege principle * Audit secret access regularly * Delete unused secrets promptly ### Monitoring * Set up alerts for failed secret decryption * Monitor secret usage in logs * Track secret rotation dates * Get notified when secrets expire ## Troubleshooting ### Secret Not Found Error **Error:** `Secret 'stripe_key' not found` **Causes:** * Typo in secret name * Secret was deleted * Wrong syntax (forgot `secret:` prefix) **Fix:** 1. Check secret exists: **Secrets** page 2. Verify name spelling matches exactly 3. Check template syntax: `{{secret:name}}` (not `{{name}}`) ### Template Not Replaced **Symptoms:** Backend receives literal `{{secret:stripe_key}}` instead of actual value **Causes:** * Wrong template syntax * Secret referenced but not found * Header injection disabled **Debug:** 1. Check logs - see what was actually sent 2. Verify template syntax is correct 3. Test secret exists and name matches 4. Check inject headers is enabled ### Wrong Value Injected **Symptoms:** Backend gets wrong API key **Causes:** * Using wrong environment * Secret not configured for environment * Old version being used **Fix:** 1. Check which environment request used 2. Verify secret configured for that environment 3. Check secret version (maybe need to create new version) 4. Look at logs to see which value was injected ## Next Steps Now that you understand secrets: Set up OAuth with auto-refresh Advanced secret rotation & monitoring Use different secrets per environment Different secrets per HTTP method ## Related Concepts * **Environment tab**: Different secrets per dev/staging/prod * **Template System**: `{{secret:name}}` syntax for injection * **Encryption**: AES-256-GCM with envelope encryption * **OAuth2 Secrets**: Automatic token refresh for OAuth flows *** * **Level**: beginner * **Time**: 10 minutes `secrets`, `security`, `credentials`, `encryption`, `quickstart` # AI Onboarding Agent Source: https://docs.knoxcall.com/getting-started/onboarding-agent Automated setup powered by AI. Install a KnoxCall Agent on your host and let Claude analyze your codebase to automatically discover APIs, detect secrets, and create routes. # AI Onboarding Agent Skip manual setup. Install a KnoxCall Agent on your host, then start a conversation. The AI Onboarding Agent analyzes your codebase through the installed agent, discovers API integrations, detects credentials, and creates all necessary KnoxCall resources through a conversational interface. The onboarding agent is a **cloud-only** feature (KnoxCall SaaS); it is not available on self-hosted deployments. SSH-based onboarding (entering a host, port, username, and SSH password/key) has been **retired** — you now install a lightweight KnoxCall Agent on your host and start a conversation bound to that agent. ## What is the Onboarding Agent? The Onboarding Agent is a conversational AI assistant powered by Claude that: * 🔍 **Scans your codebase** to find external API calls * 🔑 **Detects secrets** in .env files and environment variables * 🤖 **Suggests resources** (routes, secrets, clients) to create * 💬 **Asks clarifying questions** when it needs your input * ✨ **Creates everything automatically** once you approve ### How It Works ```text theme={"dark"} 1. Install KnoxCall Agent ↓ 1-line install on your host (Agents → "1-line install") 2. Start a Conversation ↓ Select repositories / code directories to analyze 3. AI Analysis ↓ Discovers endpoints, secrets, auth patterns 4. Review & Approve ↓ Agent shows findings, asks questions 5. Automatic Creation ↓ Routes, secrets, clients all created ``` ## Quick Start ### Step 1: Install a KnoxCall Agent 1. Navigate to **Agents** in the admin UI 2. Click **1-line install** and copy the install command 3. Run the command on the host you want to onboard. The agent registers itself back to KnoxCall and appears in the Agents list as **active**. You don't enter any SSH host, port, username, or password — the agent runs locally on your host and connects out to KnoxCall. ### Step 2: Start a Conversation 1. Navigate to **Onboarding** (or open the agent on the **Agents** page) 2. Click **New Conversation** and pick the installed agent you want to onboard 3. KnoxCall creates an onboarding session bound to that agent and opens the conversation page Behind the scenes this calls `POST /admin/agents/:id/start-conversation`, which provisions the session and queues the agent to begin work. ### Step 3: Select Repositories Browse the host's filesystem (via the installed agent) and select code directories to analyze: ```text theme={"dark"} ✅ /var/www/api-backend ✅ /home/deploy/microservices/payment-service ✅ /opt/apps/user-service ❌ /var/log (skip - no code here) ``` Click **Start Agent** to begin analysis. ### Step 4: Chat with the Agent The agent will greet you and explain what it's doing: ```text theme={"dark"} Agent: Hello! I'll scan your codebase to discover API integrations. I've found 3 repositories to analyze. Starting scan... Agent: Found 47 API calls in your code. Let me classify them... Agent: I discovered calls to: - Stripe API (15 endpoints) - Twilio API (8 endpoints) - SendGrid API (5 endpoints) Are these external APIs you want to proxy through KnoxCall? You: Yes, all of them Agent: Great! I also found credentials in your .env files: - STRIPE_SECRET_KEY - TWILIO_AUTH_TOKEN - SENDGRID_API_KEY Should I create secrets for these? You: Yes please ``` ### Step 5: Review & Approve The agent presents a summary of what it will create: ```yaml theme={"dark"} Collections: - Payment APIs (Stripe, PayPal) - Communication APIs (Twilio, SendGrid) Routes: - 15 Stripe routes (charges, customers, subscriptions) - 8 Twilio routes (SMS, voice, verify) - 5 SendGrid routes (mail send, templates) Secrets: - stripe_secret_key (detected: API key) - twilio_auth_token (detected: Auth token) - sendgrid_api_key (detected: API key) Client: - Production Server (198.51.100.50) ``` Type `approve` to confirm, or ask questions to refine. ### Step 6: Automatic Creation The agent creates everything: ```text theme={"dark"} ✅ Created collection: Payment APIs ✅ Created secret: stripe_secret_key ✅ Created route: Stripe Charges API ✅ Created route: Stripe Customers API ... (continues for all resources) ✅ Created client: Production Server ✅ Linked client to all routes Done! Created 28 routes, 3 secrets, 2 collections, and 1 client. ``` ## Features ### Intelligent Code Analysis The agent uses Claude's AI to: **Detect API Calls:** ```javascript theme={"dark"} // Detected: Stripe API const charge = await stripe.charges.create({ amount: 2000, currency: 'usd', source: token }); // Detected: Twilio API await twilioClient.messages.create({ to: phoneNumber, from: config.twilioNumber, body: message }); ``` **Classify Internal vs External:** ```javascript theme={"dark"} // EXTERNAL - Will create route fetch('https://api.stripe.com/v1/charges') // INTERNAL - Skipped fetch('/api/internal/users') ``` **Extract Authentication Patterns:** ```php theme={"dark"} // Detected: Bearer token auth $headers = ['Authorization: Bearer ' . $apiKey]; // Detected: Basic auth curl_setopt($ch, CURLOPT_USERPWD, "$username:$password"); // Detected: API key in query $url = "https://api.example.com/data?apiKey=" . $key; ``` ### Secret Detection Finds credentials in multiple formats: **.env Files:** ```bash theme={"dark"} STRIPE_SECRET_KEY=sk_live_abc123 TWILIO_AUTH_TOKEN=AC123456789 DATABASE_PASSWORD=supersecret ``` **PHP Config:** ```php theme={"dark"} define('API_KEY', 'pk_live_xyz789'); $config['stripe_key'] = 'sk_test_123'; ``` **JavaScript/TypeScript:** ```javascript theme={"dark"} const SENDGRID_API_KEY = 'SG.abc123'; process.env.STRIPE_SECRET_KEY ``` **Python:** ```python theme={"dark"} STRIPE_KEY = "sk_live_abc123" api_key = os.getenv('TWILIO_AUTH_TOKEN') ``` The agent automatically: * Suggests descriptive names (`stripe_secret_key` not `env_var_42`) * Detects secret type (API key, OAuth token, password) * Shows preview (first 4 chars) without exposing full value * Groups secrets by service ### Conversational Interaction The agent can: **Ask clarifying questions:** ```text theme={"dark"} Agent: I found calls to api.internal-analytics.com Is this an external service or your own infrastructure? You: That's our own service, skip it Agent: Got it, I'll ignore api.internal-analytics.com ``` **Handle ambiguity:** ```text theme={"dark"} Agent: The endpoint "POST /payments" is called with different base URLs in different files: - https://api.stripe.com (prod) - https://api-sandbox.stripe.com (dev) Should I create separate routes or use environments? You: Use environments Agent: Perfect, I'll create one route with production and development environments ``` **Provide context:** ```text theme={"dark"} You: Why do you need my Stripe key? Agent: I detected 15 Stripe API calls in your code at: - /var/www/api/payment.php (lines 45, 67, 89) - /var/www/api/subscriptions.php (lines 23, 56) The STRIPE_SECRET_KEY from your .env file will be stored encrypted and injected when proxying these calls. You can choose not to import it if you prefer manual setup. ``` ### Pause & Resume The agent saves its state, allowing you to: * Disconnect and return later * Review findings before proceeding * Consult with your team * Re-run analysis on the same server ## Advanced Usage ### Bulk Domain Classification Instead of classifying each endpoint individually, classify by domain: ```text theme={"dark"} Agent: I found endpoints from these domains: 🌐 api.stripe.com (15 endpoints) EXTERNAL | High confidence | Payment processing 🌐 api.twilio.com (8 endpoints) EXTERNAL | High confidence | Communication 🌐 internal-api.yourcompany.com (47 endpoints) UNKNOWN | Needs your input 🌐 analytics.yourcompany.com (12 endpoints) UNKNOWN | Needs your input You: Stripe and Twilio are external. The others are internal, skip them. Agent: ✅ Marked 23 external endpoints ⏭️ Skipped 59 internal endpoints ``` ### Repository Re-Analysis Start a new conversation against the same agent to re-scan the same host: ```text theme={"dark"} Use case: You've updated your code and want to discover new APIs without reinstalling anything. 1. Open the agent on the Agents page (or the Onboarding page) 2. Click "New Conversation" 3. Select the same repositories 4. Agent runs fresh analysis with updated code ``` ### Custom Collections Organize routes into logical groups: ```text theme={"dark"} Agent: I can create these collections: - Payment APIs (Stripe, PayPal) - Communication (Twilio, SendGrid) - Data Storage (AWS S3, Google Cloud Storage) You: Create separate collections for each service Agent: ✅ Will create 5 collections instead of 3 OR You: Put everything in one collection called "Production APIs" Agent: ✅ All routes in single collection "Production APIs" ``` ### Multi-Environment Setup The agent can configure environments during creation: ```text theme={"dark"} Agent: I found these endpoint patterns: Production: https://api.stripe.com Sandbox: https://api-sandbox.stripe.com (in test files) Create environments for both? You: Yes Agent: ✅ Creating routes with "production" and "sandbox" environments You'll need to configure sandbox credentials later ``` ## Security & Privacy ### Read-Only Access The agent operates with **read-only filesystem access** on your host: * ✅ Can list directories * ✅ Can read file contents * ❌ **Cannot** execute commands * ❌ **Cannot** write files * ❌ **Cannot** modify permissions * ❌ **Cannot** delete anything ### Credential Handling **Host Access:** * KnoxCall never receives or stores SSH credentials for your host * The KnoxCall Agent runs locally on your host and connects out to KnoxCall — KnoxCall never logs into your server * The agent only reads the directories you select during the conversation **Detected Secrets:** * Full values **never** shown in UI * Only first 4 characters displayed (preview) * You must provide actual values during resource creation * Agent cannot access production secret values **Created Secrets:** * Encrypted using AES-256-GCM * Stored with per-secret encryption keys * Keys encrypted with master key * Zero-knowledge architecture ### Network Isolation * The KnoxCall Agent runs on your host (not in your browser); the conversation orchestration runs server-side * The agent connects out to KnoxCall — KnoxCall never opens an inbound connection to your server * No third-party services involved * No data sent to external APIs (except Claude for analysis) ### Audit Trail All agent actions are logged: ```sql theme={"dark"} -- Conversation history SELECT * FROM onboarding_conversation_messages WHERE session_id = 'your-session-id'; -- Files accessed SELECT tool_input->>'path' FROM onboarding_conversation_messages WHERE tool_name = 'read_file'; -- Resources created SELECT created_route_id, created_secret_id FROM onboarding_discovered_endpoints; ``` ## Supported Languages & Frameworks ### Fully Supported **Backend Languages:** * JavaScript / Node.js * TypeScript * PHP * Python * Ruby * Go * Java **Frameworks:** * Express.js, Koa, Fastify (Node) * Laravel, Symfony (PHP) * Django, Flask, FastAPI (Python) * Rails, Sinatra (Ruby) * Gin, Echo (Go) * Spring Boot (Java) ### Detected Patterns **HTTP Clients:** ```javascript theme={"dark"} // Node.js fetch(), axios, got, request, superagent // PHP curl_exec(), file_get_contents(), Guzzle // Python requests, httpx, urllib, aiohttp // Ruby Net::HTTP, RestClient, Faraday, HTTParty // Go http.Client, resty // Java HttpClient, OkHttp, RestTemplate ``` **Environment Variables:** ```bash theme={"dark"} # .env files .env, .env.local, .env.production, .env.development # Framework-specific config/secrets.yml (Rails) .env.php (Laravel) settings.py (Django) application.properties (Spring) ``` ## Troubleshooting ### Connection Issues **Problem: "Agent not connected" / "Agent offline"** ```text theme={"dark"} Solution: 1. Confirm the KnoxCall Agent shows as "active" on the Agents page 2. Verify the agent process is running on your host 3. Confirm the host can reach KnoxCall outbound over HTTPS/WSS 4. Re-run the 1-line install if the agent was removed or revoked ``` **Problem: "Permission denied when reading files"** ```text theme={"dark"} Solution: 1. Ensure the OS user the agent runs as has read access to code directories 2. Check: ls -la /var/www/yourapp 3. May need to add the agent's user to the www-data group: sudo usermod -a -G www-data ``` ### Analysis Issues **Problem: "No endpoints discovered"** ```text theme={"dark"} Possible causes: 1. Code uses indirect API calls (variables instead of strings) 2. Selected wrong directory (no source code) 3. Unsupported language/framework Solution: - Manually review files at: /var/www/yourapp/src - Look for API client initialization - Check if framework uses config files instead of inline URLs ``` **Problem: "Too many false positives"** ```text theme={"dark"} Agent detected internal URLs as external APIs Solution: - Use bulk domain classification - Mark internal domains as "skip" - Agent learns: skips similar patterns ``` **Problem: "Secret values not detected"** ```text theme={"dark"} Agent found .env files but no secrets Possible causes: 1. Environment variables loaded at runtime (not in files) 2. Secrets stored in external vault (AWS Secrets Manager, etc.) 3. Config files in non-standard locations Solution: - Manually create secrets after onboarding - Point agent to custom config directories ``` ### Agent Behavior **Problem: "Agent stopped responding"** ```text theme={"dark"} Solution: 1. Check that the KnoxCall Agent is still active on the Agents page 2. Refresh page (agent state persists) 3. Send a message to wake agent 4. Check /var/www/knoxcall/logs for errors ``` **Problem: "Agent is stuck in a loop"** ```text theme={"dark"} Agent keeps asking same question Solution: 1. Answer with specific details: ❌ "yes" ✅ "Yes, all Stripe endpoints are external" 2. If unclear, ask agent: "What do you need from me?" 3. Last resort: restart session (data preserved) ``` ## API Reference ### REST Endpoints All onboarding endpoints live on the **admin host** (`admin.knoxcall.com`, or any `knoxcall.com` host), under the `/admin` prefix. They are authenticated by an admin **session JWT** plus the `X-Tenant-ID` header (owner/admin role) and are scoped to your tenant — they are **not** part of the `/v1/*` client API on `api.knoxcall.com` and do not accept an API key. They are also **cloud-only**: requests fail on self-hosted deployments. **Start a Conversation (create session):** ```http theme={"dark"} POST /admin/agents/{agentId}/start-conversation { "root": "/srv/app" // optional: filesystem root to scan } ``` Returns `{ "session_id": "", "command_id": "" }`. This is the supported entry point — it creates an onboarding session bound to an installed KnoxCall Agent. The legacy `POST /admin/onboarding/sessions` (passing `host`/`port`/`username`/`auth_type`) has been **retired** and now returns `410 Gone`. Install a KnoxCall Agent and use `POST /admin/agents/{agentId}/start-conversation` instead. **Send Message:** ```http theme={"dark"} POST /admin/onboarding/sessions/{id}/messages { "content": "Yes, create all routes" } ``` **Stream Events (SSE):** ```http theme={"dark"} GET /admin/onboarding/sessions/{id}/stream?token={jwt} Events: - agent.message: Agent sent a message - agent.thinking: Agent is processing - agent.tool_call: Agent using a tool (read_file, etc.) - agent.tool_result: Tool execution completed - agent.action_needed: Agent needs user input - agent.error: Error occurred - agent.complete: Session complete ``` ### Event Types **agent.message:** ```json theme={"dark"} { "id": "msg_abc123", "content": "I found 15 Stripe API calls", "sequence": 42 } ``` **agent.tool\_call:** ```json theme={"dark"} { "id": "tool_xyz789", "toolName": "read_file", "toolInput": { "path": "/var/www/api/payment.php", "offset": 1, "limit": 100 } } ``` **agent.action\_needed:** ```json theme={"dark"} { "id": "action_123", "actionType": "approve_routes", "message": "Should I create these 15 routes?", "options": { "routes": [...] } } ``` ## Best Practices ### Before Starting 1. **Review your code** - Ensure .env files are up to date 2. **Install the KnoxCall Agent** - Run the 1-line install on your host and confirm it shows as active 3. **Backup credentials** - Agent won't access vault secrets; have them ready 4. **Clean up test code** - Remove old API integrations you're not using ### During Analysis 1. **Be specific** - "All Stripe endpoints are external" vs "yes" 2. **Group by service** - Create collections like "Payment APIs", not "APIs" 3. **Use environments** - One route with dev/prod environments, not separate routes 4. **Verify suggestions** - Agent's AI is smart but review before approving ### After Creation 1. **Test routes** - Use the testing UI to verify proxying works 2. **Configure secrets** - Add environment-specific secret values if needed 3. **Set rate limits** - Agent doesn't configure these; set manually 4. **Review client permissions** - Ensure client IP is correct ### Security Checklist * [ ] Installed the KnoxCall Agent with a read-only OS user where possible * [ ] Reviewed all discovered secrets before creating * [ ] Verified no sensitive data in agent conversation * [ ] Checked created routes target correct environments * [ ] Tested with non-production credentials first * [ ] Deleted onboarding session after completion (optional) ## Limitations ### What the Agent **Cannot** Do * Execute commands on your server * Modify your source code * Access databases directly * Read secrets from external vaults (AWS, HashiCorp) * Detect API calls made via SDK without explicit URLs * Parse compiled/minified code * Read binary files ### Edge Cases **Dynamic URLs:** ```javascript theme={"dark"} // ❌ Agent cannot detect const baseUrl = config.getUrl(); fetch(baseUrl + '/api/endpoint'); // ✅ Agent can detect fetch('https://api.stripe.com' + '/v1/charges'); ``` **Runtime Config:** ```python theme={"dark"} # ❌ Agent cannot find these secrets # (loaded from AWS Secrets Manager at runtime) client = boto3.client('secretsmanager') secret = client.get_secret_value(SecretId='prod/api/key') # ✅ Agent can find these # (.env file) API_KEY=sk_live_abc123 ``` **Indirect Calls:** ```php theme={"dark"} // ❌ Hard to detect $apiClient->call('charge.create', $params); // ✅ Easy to detect curl_exec($ch, "https://api.stripe.com/v1/charges"); ``` ## Next Steps Manually create a route if agent missed one Learn about secret encryption and environments Configure dev, staging, and production Restrict which clients can call routes *** * **Level**: Beginner to Intermediate * **Time**: 10-30 minutes (depends on codebase size) * **Prerequisites**: A KnoxCall Agent installed on your host (cloud-only feature) `onboarding`, `automation`, `ai`, `agent`, `setup` # Provision with Terraform Source: https://docs.knoxcall.com/getting-started/provision-with-terraform The IaC path through onboarding: a route, its environments and its secret in ~40 lines of HCL, with a secret value that never enters your Terraform state. # Provision with Terraform Everything in the [quick start](/getting-started/quick-start-guide) can be done in the dashboard. This page is the same onboarding in HCL, for teams whose answer to "where is that configured?" has to be "in the repo". By the end you will have a route, three environments with their own upstream configuration, and one secret whose value is not in your state file. **The provider is not installable yet.** It has no released version: it is not on the Terraform Registry or the OpenTofu registry, and its source is not public, so you cannot run this page today. It documents the flow ahead of the release. To set up the same route, environments and secret now, follow the [quick start](/getting-started/quick-start-guide) in the dashboard. See the [Terraform provider guide](/sdks/terraform#install). ## Prerequisites 1. **Terraform >= 1.11 or OpenTofu >= 1.11.** Write-only attributes — the mechanism that keeps secret values out of state — do not exist below that in either tool. 2. **The provider** — not installable yet; see the notice above and the [Terraform provider guide](/sdks/terraform#install). 3. **An OAuth client for your tenant**, exported as `KNOXCALL_CLIENT_ID` and `KNOXCALL_CLIENT_SECRET`. Create one under **Settings → API**. 4. Your tenant slug, and the upstream credential you want KnoxCall to hold. ## The module Around 40 lines. Copy it into `main.tf`, change the names and the upstream URL, and read the notes underneath before you apply. ```hcl theme={"dark"} terraform { required_version = ">= 1.11" required_providers { knoxcall = { source = "knoxcall/knoxcall" } } } provider "knoxcall" { # Reads KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET from the environment. tenant_slug = "acme" } variable "stripe_key" { description = "Upstream credential. Supply via TF_VAR_stripe_key; do not commit it." type = string sensitive = true } # "production" already exists as the tenant default, so declare only the others. resource "knoxcall_environment" "env" { for_each = toset(["development", "staging"]) name = each.key } # The value is write-only: Terraform sends it and forgets it. resource "knoxcall_secret" "stripe" { name = "stripe-key" value_wo = var.stripe_key value_wo_version = 1 # bump to rotate } # The route owns its BASE environment's configuration (production). resource "knoxcall_route" "payments" { name = "stripe-payments" target_base_url = "https://api.stripe.com" inject_headers_json = jsonencode({ Authorization = "Bearer {{secret_id:${knoxcall_secret.stripe.id}}}" }) rate_limit_enabled = true rate_limit_requests = 600 rate_limit_window_sec = 60 } # Every other environment is its own row, with its own upstream and limits. resource "knoxcall_route_environment" "payments" { for_each = knoxcall_environment.env route_id = knoxcall_route.payments.id environment = each.value.name target_base_url = "https://api.stripe.com" inject_headers_json = jsonencode({ Authorization = "Bearer {{secret_id:${knoxcall_secret.stripe.id}}}" }) rate_limit_enabled = true rate_limit_requests = 60 rate_limit_window_sec = 60 } ``` Four things in there are worth understanding rather than copying: * **`production` is not declared.** A route's `base_environment` defaults to `production`, and `knoxcall_route` owns that environment's configuration row itself. `knoxcall_route_environment` refuses the base environment for exactly that reason — two resources managing one row is how a module fights itself. * **`{{secret_id:}}` is resolved server-side**, at proxy time. The header your upstream receives carries the real credential; the header your route *configuration* carries is a reference to a UUID. Nothing in Terraform ever holds the value. * **`value_wo` is write-only and `value_wo_version` is the rotation trigger.** Terraform never sees a write-only value, so it cannot notice you changed one. Changing `value_wo` alone does nothing at all; the integer is what says "this is different now". * **The `for_each` on `knoxcall_route_environment` iterates the environment resources**, not a list of names. That is what makes Terraform create the environment before the override that references it. **Each environment needs its own value for the secret, and the provider cannot write those yet.** `knoxcall_secret.value_wo` sets the value for the secret's **base environment** — the tenant's default. A request carrying `x-knoxcall-environment: staging` looks up the staging value and, if nobody has set one, fails with ``` Secret 'stripe-key' (9c8b7a65-…) has no value configured for environment 'staging' ``` Add the other environments' values in the dashboard (**Secrets → your secret → Environments**) or through the API: ```sh theme={"dark"} curl -X PUT "https://api.knoxcall.com/v1/secrets/$SECRET_ID/value?environment=staging" \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"value":"sk_test_..."}' ``` A per-environment value is not a Terraform resource today. That is a real gap, not a recommendation — it is stated here rather than left for you to discover on the first staging request. ## Apply ```sh theme={"dark"} export KNOXCALL_CLIENT_ID=tk_live_... export KNOXCALL_CLIENT_SECRET=... export TF_VAR_stripe_key=sk_live_... terraform init terraform plan terraform apply ``` ## Call the route The route is live as soon as the apply finishes: ```bash theme={"dark"} TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) curl -X GET "https://a1b2c3d4.acme.knoxcall.com/v1/charges" \ -H "x-knoxcall-route: stripe-payments" \ -H "Authorization: Bearer $TOKEN" ``` KnoxCall matched the route by the `x-knoxcall-route` header, decrypted the secret referenced by the route's injected `Authorization` header, and forwarded the request upstream with it. Your caller never held the Stripe key, and neither does your state file. Add `-H "x-knoxcall-environment: staging"` and the same route resolves through the `staging` row instead — its own upstream URL, its own rate limit, its own value for the secret. Omit the header and you get the tenant's default environment. ## Rotate the secret Rotation is one integer: ```diff theme={"dark"} resource "knoxcall_secret" "stripe" { name = "stripe-key" value_wo = var.stripe_key - value_wo_version = 1 + value_wo_version = 2 } ``` `terraform apply` sends the new value and nothing else. The plan shows the version change, never the value. Server-side it is recorded as a `rotate` event in your tenant's audit log, which is where the history of a value Terraform cannot display actually lives. Changing `value_wo` **without** bumping `value_wo_version` is inert: the new value is not sent, no diff appears, and the apply reports success. Drive both from one variable where you can. ## What ends up in your state file The point of the module above is what is missing from `terraform.tfstate`: the Stripe key. A value you write into `value_wo` is read from your configuration, sent to the API, and nullified by Terraform before it can reach a plan or a state file — and KnoxCall's API never returns a stored secret value, so there is nothing to read back either. Two things this does *not* cover, both stated in full on the [provider page](/sdks/terraform#secrets-and-terraform-state): * Credentials **KnoxCall mints and hands back once** — a webhook's generated `secret_key`, an API key's plaintext, an OAuth client's secret, an issued client-certificate private key — are captured into state, because a value the server generates has to be `Computed`, and the plugin framework forbids `Computed` together with write-only. Each has a documented alternative; the webhook one is to sign with `hmac_key_id` instead, so no signing secret exists anywhere in Terraform. * **A rotation performed outside Terraform is invisible to `plan`** unless you pin `value_version`. Terraform has no copy of the value to compare. ## Where to go next * [Terraform provider guide](/sdks/terraform) — authentication modes, the sandbox data space, the full resource and import-ID map, and the client's retry/idempotency behaviour. * [Creating your first route](/getting-started/first-route) — what a route actually does at request time. * [Environments](/getting-started/first-environment) — how per-environment overrides resolve. # Quick Start Guide Source: https://docs.knoxcall.com/getting-started/quick-start-guide Get started with KnoxCall in minutes. Follow our QuickStart wizard to create your first client, route, and secret. # Quick Start Guide Welcome to KnoxCall! This guide walks you through the same steps as our in-app QuickStart wizard. You'll learn the three core concepts of KnoxCall: **Clients**, **Routes**, and **Secrets**. ## What is KnoxCall? KnoxCall is an **API gateway and reverse-proxy platform** that sits between your clients and your backend APIs. It provides: * 🔐 **Security**: API keys, IP whitelisting, request signing, encrypted secrets * 📊 **Monitoring**: Real-time logs, analytics, geo-tracking, alerts * 🔧 **Flexibility**: Environment switching, secret injection, rate limiting * 🌍 **Compliance**: VPN egress routing for data sovereignty (HIPAA, GDPR) ## The Three Core Concepts Before you start, understand these three foundational concepts: ### 1. Clients = IP Addresses (Authorization) **Clients** are authorized IP addresses that can call your routes. They provide network-level security. **Think of it as:** "Which servers/devices are allowed to make requests?" **Examples:** * Your production server: `52.123.45.67` * Your office network: `192.168.1.0/24` * Your development laptop: `203.45.67.89` ### 2. Routes = API Proxies (Forwarding Rules) **Routes** define how incoming requests are forwarded to your backend APIs. **Think of it as:** "Where should KnoxCall send this request?" **Example flow:** ```text theme={"dark"} Client → KnoxCall Route → Your Backend API ``` ### 3. Secrets = Encrypted Credentials (Security) **Secrets** store API keys, tokens, and passwords encrypted on KnoxCall's servers. They're injected into requests server-side so clients never see them. **Think of it as:** "How do I pass API keys to my backend without exposing them?" ## Step 1: Create Your First Client Let's add your IP address as an authorized client. ### 1.1: Find Your IP Address First, determine your current IP: ```bash theme={"dark"} curl https://api.ipify.org ``` Copy this IP address (e.g., `203.45.67.89`) ### 1.2: Navigate to Clients 1. Expand **Proxy** in the sidebar 2. Select **Clients** 3. Click **Create Client** button ### 1.3: Fill in Client Details **Client Name:** ```text theme={"dark"} My Development Machine ``` **Type:** Select **User** *For development laptops/workstations* **IP Address:** ```text theme={"dark"} 203.45.67.89 ``` *Paste your IP from step 1.1* **Description** (optional): ```text theme={"dark"} My laptop for testing KnoxCall ``` **Enabled:** ✓ ON ### 1.4: Save Click **Create Client** Your IP is now whitelisted and ready to make requests! ✅ **Production Tip:** For production servers, use **Server** type and static IP addresses (like AWS Elastic IPs). For development, **User** type is fine for dynamic IPs. ## Step 2: Create Your First Route Now let's create a route to forward requests to a backend API. ### 2.1: Navigate to Routes 1. Expand **Proxy** in the sidebar 2. Select **Routes** 3. Click **Create Route** button ### 2.2: Configure Basic Settings **Route Name:** ```text theme={"dark"} test-api ``` *Lowercase with hyphens. You'll use this in requests.* **Target Base URL:** ```text theme={"dark"} https://jsonplaceholder.typicode.com ``` *We'll use a public test API for this tutorial* **Base Environment:** ```text theme={"dark"} production ``` *This is your default environment (already exists)* **Enabled:** ✓ ON ### 2.3: Security Settings Leave defaults for now: * **Requires Clients:** ✓ ON (IP whitelist enabled) * **Require Signature:** OFF * **Rate Limiting:** OFF We'll configure advanced security later. ### 2.4: Save Click **Create Route** Your route is now live! 🚀 ### 2.5: Assign Your Client to the Route Now connect your client (IP address) to your route: 1. Find your route in the list 2. Click on "test-api" 3. Go to **Clients** tab 4. Select "production" environment 5. Click **Assign Clients** 6. Check "My Development Machine" 7. Click **Save** Your IP can now access this route! ✅ ## Step 3: Create Your First Secret Let's store a secret that can be injected into requests. ### 3.1: Navigate to Secrets 1. Expand **Protect** in the sidebar 2. Select **Secrets** 3. Click **Create Secret** button ### 3.2: Choose Secret Type Select **String Secret** *For API keys, tokens, passwords* ### 3.3: Fill in Secret Details **Secret Name:** ```text theme={"dark"} test_api_key ``` *Lowercase with underscores* **Secret Value:** ```text theme={"dark"} my-secret-api-key-12345 ``` *For this tutorial, use any test value* ### 3.4: Save Click **Create Secret** Your secret is now encrypted and stored! 🔐 The secret value is encrypted immediately. You won't be able to see the plaintext again in the UI! ## Step 4: Make Your First Request Now let's test everything together! ### 4.1: Sign In from Your Terminal Authenticate your machine once — no keys to copy: ```bash theme={"dark"} npm install @knoxcall/sdk@^1.0.0 # or: pip install "knoxcall>=1.0,<2.0" / gem install knoxcall -v "~> 1.0" / composer require knoxcall/sdk:^1.0 / go install github.com/knoxcall/sdk-go/cmd/knoxcall@v1.0.0 knoxcall login # opens your browser (PKCE); use `--device` on headless/SSH machines ``` `knoxcall login` stores a short-lived OAuth 2.1 credential in `~/.knoxcall/credentials.json`, and every KnoxCall SDK picks it up automatically. For CI or servers, [create an OAuth client](/api-reference/authentication#create-an-oauth-client) instead (dashboard → **Settings** → **API** → **OAuth Clients**) and set `KNOXCALL_CLIENT_ID` + `KNOXCALL_CLIENT_SECRET`. Long-lived API keys (`tk_…`) remain supported as a legacy alternative — see [Authentication](/api-reference/authentication). ### 4.2: Get Your Tenant Subdomain When you created your account, KnoxCall generated a unique subdomain: ```text theme={"dark"} https://{hash}.{your-slug}.knoxcall.com ``` Example: `https://a1b2c3d4.DunderMifflin.knoxcall.com` Find yours in the admin UI header or your welcome email. ### 4.3: Make a Request Now make your first proxied API call. With the SDK, one line does it: ```typescript theme={"dark"} import { KnoxCall } from "@knoxcall/sdk"; const client = new KnoxCall(); // credentials from `knoxcall login` const resp = await client.call("test-api", { path: "/posts/1" }); const post = await resp.json(); ``` Prefer raw curl? Mint a short-lived OAuth token and send it as a Bearer header: ```bash theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) curl -X GET "https://a1b2c3d4.DunderMifflin.knoxcall.com/posts/1" \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: test-api" ``` **Replace:** * `a1b2c3d4.DunderMifflin.knoxcall.com` → Your subdomain * `test-api` → Your route name Using a legacy API key instead? On the proxy it goes in `-H "x-knoxcall-key: tk_live_…"` — never as a Bearer header. ### 4.4: Expected Response You should see JSON data from the test API: ```json theme={"dark"} { "userId": 1, "id": 1, "title": "sunt aut facere repellat provident...", "body": "quia et suscipit..." } ``` **Congratulations! You just made your first request through KnoxCall!** 🎉 ## Understanding What Happened Here's the complete request flow: ```text theme={"dark"} 1. Your Request ↓ GET https://a1b2c3d4.DunderMifflin.knoxcall.com/posts/1 Headers: Authorization: Bearer kc_... (Authentication — OAuth token) x-knoxcall-route: test-api (Route selection) ↓ 2. KnoxCall Receives Request ↓ - Validates access token ✓ - Loads route "test-api" ✓ - Checks IP authorization (your client) ✓ - Builds target URL: https://jsonplaceholder.typicode.com/posts/1 ↓ 3. KnoxCall Forwards Request ↓ GET https://jsonplaceholder.typicode.com/posts/1 ↓ 4. Backend Responds ↓ { "userId": 1, "id": 1, ... } ↓ 5. KnoxCall Returns Response to You ↓ 6. Request Logged in KnoxCall Dashboard ``` ## Step 5: View Your Request Logs See your request in real-time: 1. Navigate to **Logs** → **API Logs** 2. Find your request (should be at the top) 3. Click to expand and see: * Timestamp * HTTP method and full URL path * Status code (should be 200) * Response time * Request headers and body * Response headers and body * Source IP and geo-location Real-time monitoring out of the box! 📊 ## What You've Learned ✅ **Clients** - You created an authorized IP address ✅ **Routes** - You created a forwarding rule to a backend API ✅ **Secrets** - You created an encrypted credential ✅ **Authentication** - You signed in once with `knoxcall login` (OAuth 2.1) ✅ **Requests** - You made your first proxied API call ✅ **Logs** - You viewed request details in real-time ## Next Steps: Level Up Now that you understand the basics, explore advanced features: Add secrets to route headers/body Set up dev, staging, production Protect your APIs from abuse Add HMAC signatures for security View traffic metrics and trends Get notified of failures ## Common Issues & Solutions ### "Route not found" **Check:** * You're using the correct subdomain (with hash prefix) * The `x-knoxcall-route` header matches your route name exactly (case-sensitive) * The route is enabled in the UI ### "Invalid API key" / authentication errors **Check:** * Your OAuth token hasn't expired (tokens last 1 hour — the SDK refreshes automatically; re-mint for curl) * Your login is still valid (`knoxcall whoami`, then `knoxcall login` again if needed) * If you use an OAuth client: it is active and `KNOXCALL_CLIENT_ID` / `KNOXCALL_CLIENT_SECRET` hold its Client ID and secret (**Settings** → **API** → **OAuth Clients**) * If you use an OAuth client: it was created in the same mode (Live or Test) you are calling, and its **Allowed scopes** include `routes:invoke` for proxied calls * Legacy keys only: you copied the full key and it hasn't been deleted * You're using the correct tenant subdomain ### "IP not authorized" **This is the most common issue for new users!** **Causes:** 1. Your IP isn't whitelisted as a client 2. Client not assigned to the route environment 3. Client is disabled 4. Your IP changed (home networks, mobile) **Fix:** 1. Check your current IP: `curl https://api.ipify.org` 2. Verify it matches your client IP in **Clients** page 3. Make sure client is assigned to route → **Clients** tab → "production" 4. Check client is enabled **Quick test:** Temporarily disable `requires_clients` on your route to test if IP authorization is the issue. ### "502 Bad Gateway" **Causes:** * Target URL is unreachable from the internet * Backend server is down * DNS resolution failed **Debug:** * Test target URL directly: `curl https://jsonplaceholder.typicode.com/posts/1` * Check backend server is running * Verify target URL in route settings ## Pro Tips ### Tip 1: Use Sandbox (Test) Mode for Development Develop against the isolated Test environment: create your OAuth client while the dashboard is in Test mode, and pass the SDK's `sandbox: true` option. Legacy test keys (`tk_test_…`) can skip IP authorization for easier development. ### Tip 2: Name Things Descriptively **Good names:** ```text theme={"dark"} production_server_1 office_network stripe_prod_key user_api_route ``` **Bad names:** ```text theme={"dark"} client1 key route secret ``` ### Tip 3: Start Simple, Add Security Later 1. First: Get basic route working 2. Then: Add IP authorization 3. Then: Add secrets 4. Then: Add rate limiting 5. Finally: Add request signing Don't try to configure everything at once! ### Tip 4: Use the Support Chat See that chat bubble in the bottom-right? That's powered by Chatwoot and monitored by our team. Ask questions anytime! ## Need Help? * 💬 **Support chat** (bottom right corner) - fastest way to get help * 📖 **Full documentation** - browse all guides and references * 📧 **Email** - [support@knoxcall.com](mailto:support@knoxcall.com) for account issues * 🐛 **Bug reports** - GitHub issues for technical problems ## Summary: The KnoxCall Workflow ```text theme={"dark"} 1. Create Clients (IP whitelist) ↓ 2. Create Routes (forwarding rules) ↓ 3. Create Secrets (encrypted credentials) ↓ 4. Assign clients to routes ↓ 5. (Optional) Inject secrets into routes ↓ 6. Sign in (knoxcall login) or register an OAuth client ↓ 7. Make requests with: - the SDK: client.call(route, ...) - or curl: Authorization: Bearer $TOKEN plus x-knoxcall-route (route name) ↓ 8. Monitor in real-time logs ``` **You're now ready to use KnoxCall!** 🚀 *** * **Level**: beginner * **Time**: 15 minutes * **Prerequisites**: None `quickstart`, `beginner`, `tutorial`, `getting-started`, `wizard` # Deployment Modes Source: https://docs.knoxcall.com/infrastructure/deployment-modes Choose between KnoxCall Cloud (managed) and self-hosted Docker, and between direct DNS routing or the Go Agent for zero-code-change onboarding. # Deployment Modes KnoxCall runs the same binary in our cloud and on your infrastructure. You choose **where the data plane lives** and **how your app reaches it** — two independent decisions, each with a sensible default. ## The 2×2 Matrix | | **Direct URL / DNS** (code or DNS change) | **Via Go Client Agent** (no code change) | | - | - | - | | **KnoxCall Cloud proxy** (managed, default) | Combo 1 — easiest | Combo 2 — no code change | | **Self-hosted `knoxcall/proxy` container** | Combo 3 — data-residency | Combo 4 — both | Most customers start on **Combo 1** (KnoxCall Cloud + Direct). Upgrade to the Go Agent when changing base URLs is impractical. Move to self-hosted when regulation requires API payloads to stay on your infrastructure. ## Decision Tree 1. **Does regulation require API payloads to never leave your network?** → Yes: [Self-hosted proxy](/infrastructure/self-hosted-proxy) 2. **Can your app change its base URL or DNS records?** → Yes: direct routing (see [Getting Started](/getting-started/first-route)) 3. **Otherwise** → [Install the Go Agent](/infrastructure/go-agent) ## Where Secrets Decrypt This is the key differentiator between cloud and self-hosted: * **KnoxCall Cloud mode**: your secrets are decrypted on our servers at request time. This is fine for 99% of customers — we never log plaintext and each secret is envelope-encrypted with a per-row key under our MASTER\_KEY\_B64. * **Self-hosted mode**: your secrets decrypt on *your* infrastructure. The cloud control plane only holds them in their re-encrypted form (wrapped with a session key rotated hourly). No plaintext secret ever leaves your VPC in this mode. ## Control Plane vs Data Plane Regardless of mode, the **control plane** — the admin UI, route definitions, secret storage, billing, analytics — always lives at knoxcall.com. What varies is the **data plane**: the service that handles your live API traffic. ```text theme={"dark"} ┌────────────────────────┐ ┌───────────────────────────┐ │ Control plane │ │ Data plane │ │ (always knoxcall.com) │ <──→ │ (cloud OR customer-run) │ │ Admin UI, routes, │ │ Handles live API traffic │ │ secrets (encrypted) │ │ │ └────────────────────────┘ └───────────────────────────┘ ``` In cloud mode, the data plane runs on our infrastructure at `{tenant}.knoxcall.com`. In self-hosted mode, you run the same `knoxcall/proxy` Docker image on your infrastructure — the control plane still holds config and pushes it down via short-lived [session bundles](/infrastructure/self-hosted-proxy#session-bundles) every hour. ## Pricing | Plan | Cloud (default) | Self-hosted option | Go Agent option | | - | - | - | - | | Free | ✅ | — | — | | Starter | ✅ | — | — | | Pro | ✅ | — | — | | Enterprise | ✅ | ✅ | ✅ | Self-hosted and the Go Agent are gated by the existing `byoAgentEnabled` feature flag, available on Enterprise only. ## Next Steps * Install the [Go Client Agent](/infrastructure/go-agent) for zero-code-change onboarding * Run the [Self-hosted proxy](/infrastructure/self-hosted-proxy) on your own Docker infrastructure * Plan a [Cloud → Self-hosted migration](/infrastructure/migration) with shadow-mode testing # Egress IP Routing & VPN Source: https://docs.knoxcall.com/infrastructure/egress-routing Route API requests through dedicated static IPs for whitelisting. Use managed KnoxCall egress servers or connect your own VPN infrastructure. # Egress IP Routing & VPN Many APIs require whitelisting specific IP addresses. KnoxCall supports routing requests through dedicated egress servers with static IPs—either managed by KnoxCall or through your own VPN infrastructure. ## The Problem **Dynamic IPs:** ```text theme={"dark"} Your server: 198.51.100.50 ↓ KnoxCall proxy: 203.0.113.100 (changes) ↓ Partner API: ❌ Rejects (IP not whitelisted) ``` **APIs requiring IP whitelisting:** * Banking and financial APIs (Open Banking, Plaid) * Government services (tax, compliance) * Enterprise B2B integrations * Payment processors (some configurations) * Internal corporate APIs ## The Solution **Static egress IP:** ```text theme={"dark"} Your server: 198.51.100.50 (any IP) ↓ KnoxCall proxy → Data plane node: 203.0.113.200 (fixed) ↓ Partner API: ✅ Accepts (203.0.113.200 is whitelisted) ``` **Benefits:** * ✅ Single static IP to whitelist * ✅ Works from any source (dev, staging, prod) * ✅ Automatic failover if egress server down * ✅ Choose geographic region * ✅ Multiple egress IPs per tenant (if needed) ## Types of Egress Routing KnoxCall supports two deployment models: ### 1. Managed Egress Servers (Recommended) **What it is:** * KnoxCall provisions and manages egress servers for you * Static IP provisioned in your chosen region * Automatic health monitoring and failover * No infrastructure management required **When to use:** * You need a quick, turnkey solution * You want KnoxCall to handle operations * You don't have existing VPN infrastructure **Regions available** (DigitalOcean region slugs): * 🇺🇸 `nyc3` — New York, USA * 🇺🇸 `sfo3` — San Francisco, USA * 🇬🇧 `lon1` — London, UK * 🇩🇪 `fra1` — Frankfurt, Germany * 🇳🇱 `ams3` — Amsterdam, Netherlands * 🇦🇺 `syd1` — Sydney, Australia * 🇸🇬 `sgp1` — Singapore **Pricing:** * Included in Pro and Enterprise plans * \$29/month per additional egress server (if you need multiple regions) ### 2. Self-Hosted VPN Integration **What it is:** * Connect KnoxCall to your existing VPN infrastructure * Routes traffic through your VPN gateway * You manage the VPN server, KnoxCall just uses it **When to use:** * You have existing VPN infrastructure * Compliance requires self-hosted networking * You need specific routing or firewall rules * You want full control over egress traffic **Supported VPN types:** * HTTP forward proxy * HTTPS forward proxy Traffic reaches your network through an HTTP(S) forward proxy you run. SOCKS5, WireGuard and OpenVPN endpoints are not dialled directly — put a forward proxy in front of them. The proxy address must resolve to a publicly routable address; loopback, private and link-local destinations are refused. **Pricing:** * Included in Enterprise plan * Contact sales for availability on lower tiers ## Setting Up Managed Egress ### Step 1: Request Egress Server 1. Navigate to **Proxy** → **Static IPs** (cloud-hosted KnoxCall only — this page is hidden on self-hosted deployments) 2. Click **Request Static IP** 3. Configure: ```yaml theme={"dark"} Type: shared # or "dedicated" (Enterprise only) Region: nyc3 # DigitalOcean region slug — New York ``` 4. Click **Request** ### Step 2: Wait for Provisioning KnoxCall automatically: * Creates a DigitalOcean Droplet in your region * Configures forward proxy software * Assigns a static IP (reserved IP) * Sets up health checks * Registers in data plane registry **Typical provisioning time:** 2-3 minutes ### Step 3: Note Your Static IP After provisioning completes: ```text theme={"dark"} Egress Server: US East Egress ├─ Status: ✅ Active ├─ Region: nyc3 (New York, USA) ├─ Egress IP: 203.0.113.200 ├─ Last seen: 12 seconds ago └─ Routes using: 0 ``` **Share this IP** with your API provider for whitelisting. ### Step 4: Assign to Routes Configure which routes use the egress server: **Option A: Per-environment (recommended)** 1. Go to route detail page 2. Select environment (e.g., "production") 3. Scroll to **Advanced** → **Egress Routing** 4. Select egress server: "US East Egress" 5. Save **Option B: Via API** ```http theme={"dark"} PUT /admin/routes/{route_id}/egress { "egress_server_id": "" } ``` Pass `egress_server_id: null` to remove the egress assignment and revert the route to direct routing. ### Step 5: Test & Verify Make a test request through your route: ```bash theme={"dark"} curl https://yourapp.com/api/v1/proxy/your-route \ -H "Authorization: Bearer your-token" ``` Check the API provider's logs to confirm they see requests from `203.0.113.200`. ## Setting Up Self-Hosted VPN ### Prerequisites * VPN server (WireGuard or OpenVPN) * VPN configuration file or credentials * Network access from KnoxCall servers ### Step 1: Prepare VPN Configuration **WireGuard example:** ```ini theme={"dark"} [Interface] PrivateKey = Address = 10.0.0.2/24 [Peer] PublicKey = Endpoint = vpn.yourcompany.com:51820 AllowedIPs = 0.0.0.0/0 PersistentKeepalive = 25 ``` **OpenVPN example:** ```text theme={"dark"} client dev tun proto udp remote vpn.yourcompany.com 1194 ca ca.crt cert client.crt key client.key ``` ### Step 2: Register VPN in KnoxCall 1. Navigate to the **VPN** page and click **Create VPN Connection** → **My Own VPN** 2. Choose your VPN type (WireGuard, OpenVPN, or custom proxy) 3. Configure: ```yaml theme={"dark"} Name: Corporate VPN Type: wireguard Configuration: (paste WireGuard config) Egress IP: 203.0.113.200 (your VPN's egress IP) Health Check URL: https://vpn-health.yourcompany.com/ping (optional) ``` 4. Click **Save** ### Step 3: Test Connection KnoxCall will: * Establish VPN tunnel * Perform health check (if configured) * Report connectivity status ```text theme={"dark"} VPN Node: Corporate VPN ├─ Status: ✅ Connected ├─ Type: WireGuard ├─ Egress IP: 203.0.113.200 ├─ Last seen: 5 seconds ago ├─ Health: Healthy (200 OK) └─ Routes using: 0 ``` ### Step 4: Assign to Routes Assign the VPN node to a route from the route detail page under **Advanced** → **Egress Routing**. ## Advanced Configuration ### Multiple Egress Servers per Tenant Use different egress IPs for different APIs: ```text theme={"dark"} Egress Servers: ├─ US East Egress (203.0.113.200) │ Routes: Stripe, Twilio │ ├─ EU West Egress (198.51.100.50) │ Routes: European banking APIs │ └─ Corporate VPN (192.0.2.100) Routes: Internal partner APIs ``` **Configuration:** * Route A (Stripe) → uses US East Egress * Route B (European bank) → uses EU West Egress * Route C (partner) → uses Corporate VPN ### Failover & High Availability **Managed egress servers:** * Automatic health monitoring (every 30 seconds) * If unhealthy, routes fall back to direct routing * Email alerts on failover * Auto-recovery when health restored **Self-hosted VPN:** * Manual health check URL (optional) * No automatic failover unless configured * You're responsible for VPN uptime ### Regional Routing Route requests through the closest region to the destination API: ```text theme={"dark"} Your app in US → Stripe API in US ↓ uses US East Egress (low latency) Your app in US → European Bank API in EU ↓ uses EU West Egress (compliance + latency) ``` ## Security Considerations ### Managed Egress Servers **Isolation:** * Dedicated Droplet per egress server (not shared) * Firewall rules restrict traffic to your routes only * No direct SSH access (managed via API) **Authentication:** * Requests authenticated with tenant-specific secret * HMAC signature verification * Replay attack prevention **Monitoring:** * All traffic logged * Health checks every 30 seconds * Alerts on anomalies ### Self-Hosted VPN **Security checklist:** * [ ] VPN credentials stored encrypted in KnoxCall * [ ] Firewall allows only KnoxCall server IPs * [ ] VPN configuration doesn't expose internal network * [ ] Health check endpoint authenticated (if public) * [ ] Egress traffic monitored on your side **Network isolation:** * Ensure VPN tunnel doesn't grant KnoxCall access to internal network * Use split tunneling (only route API traffic through VPN) * Configure firewall rules on VPN gateway ## Monitoring & Troubleshooting ### Health Status **Managed egress servers** — run an on-demand health check: ```http theme={"dark"} GET /admin/egress/{server_id}/health Response: { "server_id": "9f1c0e2a-3b4d-5e6f-7a8b-9c0d1e2f3a4b", "healthy": true, "checked_at": "2025-01-20T15:30:00Z" } ``` **Self-hosted VPN nodes** — read the node's current status: ```http theme={"dark"} GET /admin/data-plane-nodes/{id} Response: { "id": "9f1c0e2a-3b4d-5e6f-7a8b-9c0d1e2f3a4b", "status": "active", "last_seen_at": "2025-01-20T15:30:00Z", "health_check_url": "https://vpn-health.corp.com/ping", "egress_ip": "203.0.113.200", "region": "nyc3" } ``` IDs are UUIDs. To actively test a self-hosted node's connectivity, call `POST /admin/data-plane-nodes/{id}/test`. **Status values:** * `active` - Healthy and routing traffic ✅ * `unhealthy` - Failed health check ⚠️ * `unreachable` - Cannot connect 🔴 * `unknown` - Status not yet determined 🔄 ### Request Logs View which requests used egress routing: ```http theme={"dark"} GET /admin/logs Response: { "logs": [ { "route_id": "3a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d", "data_plane_node_id": "9f1c0e2a-3b4d-5e6f-7a8b-9c0d1e2f3a4b", "egress_ip": "203.0.113.200", "target_url": "https://api.stripe.com/v1/charges", "status": 200, "timestamp": "2025-01-20T15:30:00Z" } ] } ``` ### Common Issues **Problem: "Egress server unreachable"** ```text theme={"dark"} Routes using this server fall back to direct routing ``` **Solutions:** 1. Check server status in dashboard 2. If managed: wait 2-3 min for auto-recovery 3. If self-hosted: verify VPN server is running 4. Check firewall allows KnoxCall IPs **Problem: "API still rejecting IP"** ```text theme={"dark"} API provider claims IP is not whitelisted ``` **Debug steps:** 1. Verify correct egress server assigned to route 2. Check route's environment is correct 3. Test with: `curl https://ifconfig.me/ip` through egress server 4. Confirm API provider whitelisted the right IP 5. Check for typos in IP address (common!) **Problem: "Latency increased after enabling egress"** ```text theme={"dark"} Requests now take 200ms longer ``` **Causes:** * Egress server in wrong region (e.g., US egress for EU API) * VPN has high latency **Solutions:** * Use egress server closest to destination API * For self-hosted VPN: measure VPN latency * Consider multiple regional egress servers **Problem: "VPN connection keeps dropping"** ```text theme={"dark"} Self-hosted VPN shows "unreachable" intermittently ``` **Solutions:** 1. Check VPN server logs for disconnections 2. Increase WireGuard PersistentKeepalive value 3. Configure health check to detect failures faster 4. Ensure firewall isn't blocking keep-alive packets ## Cost Optimization ### When to Use Egress Routing **Use egress when:** * ✅ API requires IP whitelisting * ✅ You need consistent source IP for logging/audit * ✅ Compliance mandates specific egress points * ✅ API has regional restrictions **Skip egress when:** * ❌ API doesn't require IP whitelisting * ❌ Added latency is critical (use direct routing) * ❌ Route has very high traffic (cheaper to provision own server) ### Multi-Tenant Cost Sharing For enterprise deployments with many routes: ```text theme={"dark"} Option A: Separate egress per route → 10 routes = 10 egress servers = $290/month Option B: Shared egress server → 10 routes = 1 egress server = $29/month → All use same egress IP ``` Most APIs accept multiple routes from same IP, so Option B is usually fine. ## API Reference Managed egress servers have their own API surface under `/admin/egress`. The data-plane-node endpoints (`/admin/data-plane-nodes`) are for self-hosted VPN nodes. All of these endpoints are cloud-mode only. #### Supporting endpoints ```http theme={"dark"} GET /admin/egress/regions # list available DigitalOcean regions GET /admin/egress/pricing # shared / dedicated monthly pricing GET /admin/egress/availability/{region} # is a shared server available now? ``` ### Request a Managed Egress IP ```http theme={"dark"} POST /admin/egress { "type": "shared", // "shared" or "dedicated" (dedicated = Enterprise) "region": "nyc3" // DigitalOcean region slug (default: syd1) } Response: { "ok": true, "message": "No shared server available in this region. Provisioning new server (2-3 minutes).", "is_provisioning": true, "egress_server": { "id": "9f1c0e2a-3b4d-5e6f-7a8b-9c0d1e2f3a4b", "ip_address": null, "region": "nyc3", "type": "shared", "status": "provisioning" } } ``` Polling for completion: ```http theme={"dark"} GET /admin/egress Response (when ready): { "egress_servers": [ { "id": "9f1c0e2a-3b4d-5e6f-7a8b-9c0d1e2f3a4b", "status": "active", "reserved_ip": "203.0.113.200", "region": "nyc3" } ] } ``` If provisioning fails, retry with `POST /admin/egress/{server_id}/retry`. ### Register Self-Hosted VPN `region`, `kind`, and `display_name` are required. `kind` must be `managed` or `self_hosted`; `vpn_type` (when present) must be `wireguard`, `openvpn`, or `custom`. `vpn_config_json` is a free-form blob stored verbatim — its shape is not validated, so the keys below are just an example. ```http theme={"dark"} POST /admin/data-plane-nodes { "kind": "self_hosted", "region": "nyc3", "display_name": "Corporate VPN", "vpn_type": "wireguard", "vpn_config_json": { "interface": { "private_key": "...", "address": "10.0.0.2/24" }, "peer": { "public_key": "...", "endpoint": "vpn.corp.com:51820", "allowed_ips": "0.0.0.0/0" } }, "egress_ip": "203.0.113.200", "health_check_url": "https://vpn-health.corp.com/ping" } Response: { "id": "9f1c0e2a-3b4d-5e6f-7a8b-9c0d1e2f3a4b", "status": "unknown", ... } ``` The response returns the full node row; `id` is a UUID. ### Update / Test a Self-Hosted Node ```http theme={"dark"} PUT /admin/data-plane-nodes/{id} # update node fields (partial; fields are COALESCEd) POST /admin/data-plane-nodes/{id}/test # test VPN connectivity → { healthy, status, last_seen_at } ``` ### Assign Egress Server to a Route ```http theme={"dark"} PUT /admin/routes/{route_id}/egress { "egress_server_id": "9f1c0e2a-3b4d-5e6f-7a8b-9c0d1e2f3a4b" } ``` Send `"egress_server_id": null` to clear the assignment and revert to direct routing. ### List Managed Egress Servers ```http theme={"dark"} GET /admin/egress Response: { "egress_servers": [ { "id": "9f1c0e2a-3b4d-5e6f-7a8b-9c0d1e2f3a4b", "reserved_ip": "203.0.113.200", "region": "nyc3", "type": "shared", "status": "active" } ] } ``` Self-hosted VPN nodes are listed separately via `GET /admin/data-plane-nodes` (returns `{ "nodes": [...] }`). ### Release a Managed Egress Server ```http theme={"dark"} DELETE /admin/egress/{server_id} Response: { "ok": true, "message": "Egress IP allocation released" } ``` A server cannot be released while any route still uses it (returns `400` with `routes_using`). ### Check Managed Egress Health ```http theme={"dark"} GET /admin/egress/{server_id}/health Response: { "server_id": "9f1c0e2a-3b4d-5e6f-7a8b-9c0d1e2f3a4b", "healthy": true, "checked_at": "2025-01-20T15:30:00Z" } ``` ## Best Practices ### ✅ Do This 1. **Test with dummy route first** * Create test route, assign egress, verify IP * Don't configure production routes immediately 2. **Document which API requires which IP** * Keep spreadsheet of API → egress IP mappings * Share with team for future reference 3. **Set up alerts** * Email when egress server goes unhealthy * Monitor latency changes 4. **Use regional egress servers** * Match egress region to API region when possible * Reduces latency 5. **Keep egress credentials secure** * For self-hosted VPN, treat credentials as secrets * Rotate VPN keys quarterly ### ❌ Avoid This 1. **Don't use same egress for unrelated APIs** * If one API blocks your IP, all routes affected * Use separate egress servers for high-risk APIs 2. **Don't skip health checks for self-hosted VPN** * You'll only discover downtime when users complain * Set up health check URL 3. **Don't delete egress server while routes use it** * Routes will fall back to direct routing * May break IP whitelisting 4. **Don't expose internal network via VPN** * Use split tunneling * Firewall VPN gateway appropriately ## Migration Guide ### From Direct Routing to Egress **Before:** ```text theme={"dark"} Route → Direct → API (source IP changes) ``` **After:** ```text theme={"dark"} Route → Egress Server → API (source IP: 203.0.113.200) ``` **Steps:** 1. Provision egress server 2. Get static IP 3. Contact API provider to whitelist IP 4. Wait for confirmation (don't skip!) 5. Assign egress to route (test environment first) 6. Test 7. Assign to production 8. Monitor **Rollback plan:** * Clear the egress assignment (`PUT /admin/routes/{route_id}/egress` with `"egress_server_id": null`) * Traffic reverts to direct routing ## Next Steps Not yet available: how KnoxCall plans to run across regions Learn about route setup Monitor egress server health Secure your routes *** * **Level**: Intermediate to Advanced * **Time**: 15-30 minutes * **Prerequisites**: Understanding of IP whitelisting and networking `egress`, `vpn`, `networking`, `ip-whitelisting`, `infrastructure` # Go Client Agent Source: https://docs.knoxcall.com/infrastructure/go-agent Install the KnoxCall Go agent next to your app to route outbound API calls through KnoxCall with zero code changes. # Go Client Agent The Go agent runs next to your app as an HTTP(S) forward proxy on `localhost:7742`. It intercepts outbound calls to your KnoxCall-configured routes and transparently rewrites them through the KnoxCall data plane — **no code changes, no base URL swap**. ## When to Use It * Your app is on infrastructure where changing DNS records is awkward (serverless runtimes with cached DNS, managed platforms, BYOD-host environments). * You can't modify the SDK's base URL (vendor SDK that hardcodes production hosts). * You want a drop-in ZTNA-style proxy without code changes. If you *can* change base URLs, direct routing is simpler and faster — [skip the agent](/getting-started/first-route). ## Install Generate agent credentials under **Automation → Agents** first, then install on each host that runs your app. ```bash Linux / macOS theme={"dark"} curl -sSfL https://knoxcall.com/agent/install.sh | bash -s -- \ --agent-id=kc_agent_xxx \ --agent-secret=yyy \ --mode=intercept ``` ```powershell Windows theme={"dark"} iwr -useb https://knoxcall.com/agent/install.ps1 | iex # then knoxcall-agent --mode=intercept --agent-id=... --agent-secret=... ``` ```yaml docker-compose theme={"dark"} services: knoxcall-agent: image: knoxcall/agent:latest restart: unless-stopped network_mode: "host" environment: KNOXCALL_AGENT_ID: kc_agent_xxx KNOXCALL_AGENT_SECRET: yyy KNOXCALL_AGENT_MODE: intercept ``` ## How Interception Works The agent and the KnoxCall SDKs read the same answer: a Route whose **Intercept** toggle is on for the environment, from `GET /v1/wrap/intercept-manifest`. If you can change the process's code, the SDK's `intercept()` needs no agent, no listener and no certificate — see [Route-aware interception](/essentials/routes/route-aware-interception). The agent is for processes whose code you cannot change at all. The agent generates a local CA certificate on first run and installs it in the system trust store. For every outbound HTTP(S) request: 1. The app's OS/SDK HTTP proxy setting points at `localhost:7742`. 2. The agent inspects the target host. 3. **Matched routes** (host is a registered KnoxCall route target) → MITM the TLS handshake with a locally-signed certificate, rewrite to the KnoxCall proxy URL with auth headers attached. 4. **Unmatched** → pass-through unchanged; the agent never sees non-KnoxCall traffic. Your app code sees KnoxCall responses as if they came directly from the target API. ## Modes The same binary runs in six modes depending on the positional argument: | Mode | Purpose | | - | - | | `control` | Default. Persistent WebSocket to control plane; executes commands dispatched from the admin UI | | `proxy` | Full proxy data plane (self-hosted / `knoxcall/proxy` container) | | `intercept` | Long-running local MITM listener on `localhost:7742` | | `onboard` | One-shot AI-powered codebase scanner for discovering API calls + secrets | | `scan` | One-shot hardcoded-credential scanner (walks filesystem, env vars, TCP connections) | | `doctor` | One-shot connectivity and config sanity check | ## Database Proxying Database proxying is a capability of the long-running `intercept` (and `control`) modes — there is no separate positional mode for it. When the session bundle includes database routes, the agent binds one TCP listener per configured route on `127.0.0.1`. Applications connect to `127.0.0.1:{port}` as if connecting to the database directly — the agent handles all authentication to the upstream database and pipes the connection through. This is used as part of [Secret Store Migration Bucket B](/essentials/migrations/bucket-b-databases) to migrate database credentials from cloud secret stores (AWS Secrets Manager, Azure Key Vault, GCP Secret Manager) into KnoxCall with zero credential exposure. ```bash theme={"dark"} KNOXCALL_AGENT_ID=kc_agent_xxx \ KNOXCALL_AGENT_SECRET=yyy \ knoxcall-agent intercept ``` The agent reads its database route configuration from the KnoxCall session bundle (pushed from the control plane). Each route specifies: protocol (postgres/mysql/mongodb/redis/sqlserver), local port, upstream host/port, and credential secret reference. See [Agent database proxy protocols](/essentials/migrations/agent-db-proxy) for wire-level details on each supported protocol. ## Tamper Seal Every agent binary ships with a `build_sig` — an HMAC-SHA256 of the binary hash signed with a CI-only secret. The control plane validates this signature on every session fetch and refuses sessions for unknown builds in strict mode (`KNOXCALL_STRICT_TAMPER_CHECK=true`). In the default alert-only mode, unknown builds still get sessions but raise a visible warning in the admin UI. This means a modified agent binary cannot simply reuse captured credentials — the control plane recognises it as foreign and refuses to issue the session key. ## Operational * **Heartbeat**: every 60 seconds. Missing for >2 min = stale, >10 min = offline. Visible in Automation → Agents. * **Session renewal**: in `intercept` mode the agent renews its bundle via `/agent/v1/session/renew` on the control plane, at the `next_renew_at` time returned in each bundle (typically before the \~1-hour session expiry). (The self-hosted `proxy` container instead re-fetches via `/agent/v1/session` on its own \~hourly cycle — see [Self-Hosted Proxy](/infrastructure/self-hosted-proxy).) * **Credential rotation**: regenerate the agent secret from the Agents admin page and restart the agent — there is no downtime if you rotate before the old session expires. * **Revocation**: revoking from the admin UI takes effect on the next session-renewal cycle (within \~5 minutes). ## Agent Detail Page The **Automation → Agents → ** detail page shows: * Identity & heartbeat state * 7-day route activity (requests intercepted per route) * Tamper events (if any build\_sig verifications failed) * Revoke action ## Troubleshooting * **"Certificate trust errors"**: the local CA didn't install. Re-run `knoxcall-agent install-ca` with admin/root. * **"Connection refused on localhost:7742"**: agent crashed or exited. Check `journalctl -u knoxcall-agent` (Linux) or the Event Viewer (Windows). * **"Session denied, tamper detected"**: the running binary's `build_sig` isn't in `agent_versions`. Download a fresh official binary. # Migrating Between Cloud and Self-Hosted Source: https://docs.knoxcall.com/infrastructure/migration Cutover workflow for moving a tenant from KnoxCall Cloud to self-hosted Docker (and back), with shadow-mode testing and readiness gates. # Migrating Between Cloud and Self-Hosted This page covers switching a KnoxCall **tenant between Cloud and self-hosted deployment modes**. If you're looking to migrate secrets from AWS Secrets Manager, Azure Key Vault, or GCP Secret Manager into KnoxCall, see the [Secret Store Migrations](/essentials/migrations/overview) section instead. Switching a tenant between deployment modes is an in-place operation on `tenants.deployment_mode`. Cloud → self-hosted is gated by a readiness checklist; the reverse direction is always permitted. ## Switch Flow 1. **Set up the self-hosted proxy** on your infrastructure per the [self-hosted guide](/infrastructure/self-hosted-proxy). Container must be reachable over HTTPS. 2. **Configure the self-hosted proxy URL** under **Settings → Deployment**. 3. **Run the readiness checks** — all four must go green: * Proxy reachable (HTTPS GET `/healthz` returns 200) * Server-agent heartbeat healthy (under 2 minutes since last heartbeat) * Shadow tests (24h) — at least one successful, zero mismatches * Email provider configured (alerts, invitations, password resets) 4. **Confirm switch** — the button is disabled until the checklist passes or you explicitly force it. 5. **DNS cutover** — flip your client DNS from `tenant.knoxcall.com` to `proxy.yourdomain.com`. A 7-day cloud fallback is left warm in case you need to revert. ## Shadow Testing Shadow mode lets you compare the cloud and self-hosted proxies side-by-side *before* cutover: * **Settings → Deployment → Shadow test** fires a synthetic probe at both data planes and diffs the responses. * Each run is recorded in `migration_diffs` (30-day retention). * The readiness dashboard aggregates the last 24 hours of matches vs divergences. Run several shadow tests covering the routes that matter most — auth routes, high-volume endpoints, routes with large request bodies — before flipping. The first-cut shadow test pings `/healthz` on both sides. Route-level body comparison (sending the same authenticated request through both proxies and byte-diffing the response) is planned for a future release. ## Readiness Gates The "Switch to Self-hosted" button is enabled only when all of these hold: | Check | Threshold | | - | - | | Self-hosted proxy URL | Set, valid `http://` or `https://` | | Proxy `/healthz` | Returns **200** within 5s | | Server-agent heartbeat | under 2 min since last heartbeat (healthy) | | Shadow test history | ≥1 test run, 0 mismatches in the last 24 hours | | Email integration | An `email` row exists in `integration_configs` and a test send worked | If you need to override (you've validated through another mechanism), the API accepts `{ force: true }` — the UI surfaces this as a "Force switch" confirm button when readiness is red. ## During Migration Once you've confirmed the switch: * `tenants.deployment_mode` flips to `self_hosted`. * The cloud proxy starts returning `404` for any request addressed to this tenant. * Your self-hosted proxy begins answering traffic. * DNS change propagates (TTL-dependent — usually minutes). * The cloud tenant record remains so control-plane operations (managing routes, viewing analytics) continue to work. ## Rolling Back The reverse direction is one click: 1. **Settings → Deployment → Switch to KnoxCall Cloud** 2. DNS cutover from `proxy.yourdomain.com` back to `tenant.knoxcall.com` There's no readiness gate on the reverse switch — the cloud proxy is always available for active tenants. ## Rate Limits Shadow tests are rate-limited to **10 per minute per tenant** to protect the cloud proxy and your self-hosted container from admin-triggered load. Exceeding it returns `429` with a `Retry-After` header. ## Troubleshooting * **Readiness check stays red despite reachable proxy**: confirm the public URL is what the control plane sees. A NAT or load-balancer rewriting paths will break `/healthz`. * **`session_denied: tamper_detected`** in container logs: your `knoxcall/proxy` image's `build_sig` isn't in `agent_versions`. Pull `knoxcall/proxy:latest` and restart. * **Alerts stopped after cutover**: check **Settings → Integrations → Email**. The self-hosted container doesn't inherit the cloud's email provider — you must configure your own. * **"Bundle stale" 503 responses**: the container can't reach knoxcall.com. Check firewall / egress rules; the only outbound endpoint needed is `knoxcall.com:443`. # Multi-Region Deployment (Planned) Source: https://docs.knoxcall.com/infrastructure/multi-region Planned, not yet available. KnoxCall serves every tenant from one region today; this page describes how multi-region deployment is intended to work. # Multi-Region Deployment (Planned) **Not available today.** KnoxCall runs in a single region (DigitalOcean, New York) and serves every tenant from it. There is no multi-region deployment, no cross-region failover, no regional data residency and no availability commitment attached to any of this yet. Everything below describes **planned** functionality and may change before it ships. If you are interested in multi-region support, contact our sales team to be notified when it becomes available. When it ships, multi-region deployment is intended to run KnoxCall in several geographic regions, for lower-latency API access worldwide, data residency, and higher availability. ## Why Multi-Region? ### Performance Reduce latency by routing requests to the nearest region: ```text theme={"dark"} User in Tokyo → Asia-Pacific Region (50ms) vs. User in Tokyo → US East Region (200ms) ``` *Illustrative figures, not measurements.* ### Compliance Meet data residency and sovereignty requirements: * **GDPR**: EU data must stay in EU * **CCPA**: California data preferences * **HIPAA**: Healthcare data location requirements ### High Availability The plan: if one region goes down, traffic would route to another. Today there is one region, so there is nothing to fail over to. ```text theme={"dark"} US East: ❌ Down ↓ Automatic failover ↓ US West: ✅ Serving traffic ``` ## Planned Regions The planned region model defines four regions: **Sydney, Australia (`nz`)** * NZ + Australia data residency * Serves NZ, AU **Oregon, United States (`us`)** * Americas + Asia coverage * Primary region **Frankfurt, Germany (`eu`)** * GDPR-aligned EU data residency * Serves UK + EU **`global`** * For tenants that opt out of regional pinning * Served from any region ## How It Works ### Automatic Routing Requests are planned to be routed to the nearest region: ```text theme={"dark"} Client Location: London, UK ↓ DNS Resolution ↓ Nearest Region: EU West (Ireland) ↓ Request routed to EU West ↓ Low latency: ~20ms ``` ### Data Synchronization Your configuration is planned to be synchronized across all regions: * **Routes**: Instantly synchronized * **Secrets**: Replicated encrypted * **Clients**: Available in all regions * **Logs**: Aggregated centrally ### Region Selection Three region-selection modes are planned: #### 1. Automatic (Recommended) KnoxCall would route to the nearest region: ```text theme={"dark"} User → Closest Region ``` **Best for:** Most use cases #### 2. Pinned Region Pin a tenant to a specific region so its data is only served from that region. Region pinning is planned to be driven by the tenant's residency configuration (the `SERVER_REGION` of each regional deployment plus the tenant's assigned region), not a per-request header. **Best for:** Data residency requirements #### 3. Per-Route Region Configure default region per route: ```text theme={"dark"} Route: customer-data-api Default Region: eu (GDPR compliance) Route: global-api Default Region: auto (nearest) ``` ## Setting Up Multi-Region (Planned) ### Step 1: Enable Multi-Region (Enterprise) Multi-region cannot be enabled on any plan today. The steps below describe the planned setup: 1. Contact sales or support 2. Enable multi-region for your tenant 3. Select regions to deploy to ### Step 2: Configure Routes For each route, choose regional strategy: 1. Navigate to **Routes** → Select route 2. Go to **Regional Configuration** tab 3. Select mode: **Automatic Routing:** ```text theme={"dark"} Region Selection: Automatic Fallback Region: us ``` **Pinned Region:** ```text theme={"dark"} Region Selection: Pinned Primary Region: eu ``` **Multi-Region Active-Active:** ```text theme={"dark"} Region Selection: Active-Active Regions: us, eu, au Load Balancing: Geo-proximity ``` 4. Save configuration ### Step 3: Test Regional Routing Test from different locations by confirming requests resolve to the expected regional endpoint: ```bash theme={"dark"} curl https://a1b2c3d4.acme.knoxcall.com/api/test \ -H "x-knoxcall-route: my-route" \ -v ``` A request from a US-based client is planned to resolve to the `us` (Oregon) deployment, while a request from an EU-based client resolves to the `eu` (Frankfurt) deployment, based on the tenant's residency configuration. ## Data Residency Configuration (Planned) Planned route-level residency settings. None of them can be set today: ### GDPR Compliance (EU) ```yaml theme={"dark"} Route: eu-customer-data Regions: eu Data Residency: Enforce EU only Failover: Within EU only ``` ### US Data Residency ```yaml theme={"dark"} Route: us-customer-data Regions: us Data Residency: Enforce US only Failover: Within US only ``` ### Multi-Regional with Restrictions ```yaml theme={"dark"} Route: global-api Regions: All regions EU Traffic: Must use the eu region US Traffic: Must use the us region NZ/AU Traffic: Must use the nz region ``` ## Failover & High Availability (Planned) ### Automatic Failover (planned) If a region becomes unhealthy, the planned behaviour is: ```text theme={"dark"} Primary: US East → ❌ Health check fails ↓ Traffic switches to: US West ✅ ↓ User requests continue uninterrupted ↓ When US East recovers → Traffic returns ``` ### Health Checks The planned health checks: * **Frequency**: Every 10 seconds * **Threshold**: 3 consecutive failures triggers failover * **Checks**: * API endpoint responsiveness * Database connectivity * Latency \< 1000ms * Success rate > 95% ### Regional Status Page Once multi-region ships, per-region health will be published on the status page. The layout below is illustrative — KnoxCall serves from one region today, and no per-region availability figure is published until there is one to measure: 🟢 **US East**: Operational 🟢 **US West**: Operational 🟢 **EU West**: Operational 🟢 **EU Central**: Operational 🟢 **APAC**: Operational Visit: `https://status.knoxcall.com` ## Performance Benefits (Planned) ### Latency Reduction Modelled round-trip times for the planned topology — these are illustrative, not measurements. KnoxCall serves from a single region today: | User Location | Single Region (US East) | Multi-Region | Improvement | | - | - | - | - | | New York | 20ms | 20ms | - | | Los Angeles | 80ms | 25ms | 69% faster | | London | 120ms | 15ms | 88% faster | | Singapore | 250ms | 30ms | 88% faster | | Sydney | 200ms | 50ms | 75% faster | ### Global Coverage Map ```text theme={"dark"} 🇺🇸 US West 🇺🇸 US East 25ms 20ms ╲ ╱ ╲ ╱ User ╱ ╲ ╱ ╲ 15ms 30ms 🇪🇺 EU West 🇸🇬 APAC ``` ## Monitoring Multi-Region (Planned) ### Regional Metrics Dashboard Monitor performance per region: * **Request volume per region** * **Average latency per region** * **Error rate per region** * **Failover events** ### Alerts Set up regional alerts: ```text theme={"dark"} Alert: Region Degraded Condition: Latency > 500ms for 5 minutes Notification: Email, Slack, PagerDuty Alert: Regional Failover Condition: Traffic switched to backup region Notification: Immediate SMS, Email ``` ## Cost Considerations (Planned) ### Data Transfer Pricing for multi-region, including any cross-region data transfer, has not been set. **Optimization:** * Pin routes to single region when possible * Use multi-region only for global applications ## Best Practices (Planned) ### 1. Use Automatic Routing by Default Unless you have specific compliance needs: ```yaml theme={"dark"} Region Selection: Automatic ``` ### 2. Pin Compliance-Sensitive Routes For GDPR, HIPAA, or other compliance: ```yaml theme={"dark"} Route: patient-data Regions: us (HIPAA) Data Residency: Enforce US only ``` ### 3. Test Failover Regularly test failover: 1. Simulate region failure 2. Verify traffic switches to backup 3. Ensure data consistency ### 4. Monitor Regional Performance Set up dashboards per region: * Latency trends * Error rates * Traffic distribution ### 5. Configure Appropriate Failover **Critical routes:** ```yaml theme={"dark"} Failover: Automatic, within 30 seconds Backup regions: 2 ``` **Non-critical routes:** ```yaml theme={"dark"} Failover: Manual approval required ``` ## Troubleshooting (Planned) ### High latency despite multi-region **Check:** * DNS propagation (24-48 hours) * Regional assignment correct * Client using correct endpoint ### Requests going to wrong region **Causes:** * DNS caching * VPN routing * Pinned region configuration **Fix:** * Clear DNS cache * Verify the tenant's assigned residency region * Verify route configuration ### Failover not working **Check:** * Health checks enabled * Backup region configured * Failover policy active ## Next Steps Protect your APIs Monitor regional health Secure your routes Monitor regional performance *** * **Level**: advanced * **Time**: 15 minutes `multi-region`, `global`, `performance`, `compliance`, `high-availability` # Cloud vs Self-Hosted Parity Matrix Source: https://docs.knoxcall.com/infrastructure/parity-matrix Every feature KnoxCall Cloud supports, and how the self-hosted knoxcall/proxy container compares. Same binary, same behaviour where possible — documented divergences where infrastructure limitations apply. # Cloud vs Self-Hosted Parity Matrix The design commitment: the self-hosted `knoxcall/proxy` Docker image is **the same binary** KnoxCall Cloud runs at `*.knoxcall.com`. The router logic, the secret-injection engine, the rate limits, the audit log, the alert monitor — identical code. Where behaviour diverges it's because the underlying resource is only available in one mode, and we document each exception here. ## Quick Summary | Status | Meaning | | - | - | | ✅ **Full** | Same code path, same result — no customer work required. | | 🔑 **Customer-provided** | Same code path; you supply the credential (SMTP, Twilio, S3, etc.). | | ⚠️ **Reduced** | Works with a downgraded resource (e.g. free MaxMind vs paid); upgradeable with your key. | | ☁️ **Cloud-only** | Feature depends on KnoxCall-operated infrastructure that self-hosted doesn't replicate. | You can pull this as a live JSON document from `GET /admin/parity-audit` — the admin UI renders it under **Settings → Deployment → Feature parity**. ## Routing & Transformation | Feature | Status | Notes | | - | - | - | | Route matching by subdomain / path | ✅ Full | Same resolver; tenant resolved from Host. | | Secret injection (headers + body) | ✅ Full | Same Liquid template engine, same placeholder syntax. | | Response transformation + log obfuscation | ✅ Full | Identical rules. | | OAuth2 access-token refresh | ✅ Full | Refresh tokens re-encrypted in the session bundle, refreshed locally. | | Rolling URL / request signing | ✅ Full | Same HMAC verification. | | mTLS to upstream APIs | ✅ Full | Client certs re-encrypted in bundle, TLS terminated locally. | | HTTP method restrictions | ✅ Full | Identical allowed-method enforcement per route/environment. | | Per-route IP allowlist | ✅ Full | Client IP resolution identical (trust-proxy-1 CF headers). | | Webhook dispatch | ✅ Full | Customer-defined URLs work the same. | ## Security | Feature | Status | Notes | | - | - | - | | Per-route rate limiting | ✅ Full | Requires Redis — customer provides locally in self-hosted. | | Per-tenant rate limiting | ✅ Full | Same. | | Single-tenant enforcement (self-hosted) | ✅ Full | Self-hosted refuses foreign tenants; cloud serves all. | | Binary tamper seal | ✅ Full | Same `build_sig` verified on every session fetch. | | Machine-fingerprint binding | ✅ Full | Agent bound to its host on first fetch. [Details](/security/agent-security-model#machine-fingerprint-binding) | | Nonce + timestamp replay protection | ✅ Full | Rejected if the same nonce is seen within the 10-min window. | | Audit log hash chain | ✅ Full | Same schema + verification. | ## Analytics & Alerts | Feature | Status | Notes | | - | - | - | | Per-request API logs | ✅ Full | Written locally on self-hosted; aggregated counts sync to control plane. | | Alert monitoring + notification queue | ✅ Full | Same evaluator, delivery uses your integrations. | | AI anomaly detection | 🔑 Customer-provided | BYO Anthropic API key under Settings → Integrations. | | Real-time geo map | ⚠️ Reduced | Bundled MaxMind free DB; upgrade to your paid license for full accuracy. | | Per-tenant usage metering | ✅ Full | Usage reported to control plane every 60s for admin-UI display. | ## Integrations | Feature | Status | Notes | | - | - | - | | Email (alerts / invites / resets) | 🔑 Customer-provided | Resend / SendGrid / Postmark / Mailgun / SES / SMTP. **Required** on self-hosted. | | SMS alerts (Twilio) | 🔑 Customer-provided | Customer Twilio credentials, identical delivery logic. | | Response-body archival (S3/R2/MinIO) | 🔑 Customer-provided | Any S3-compatible endpoint. | | Slack alert webhooks | ✅ Full | Configured per-webhook. | | Stripe billing | ☁️ Cloud-only | Self-hosted plans are billed as a flat Enterprise license. | | KnoxCall-managed DigitalOcean egress | ☁️ Cloud-only | Run your own egress proxy behind the container if needed. | ## Lifecycle | Feature | Status | Notes | | - | - | - | | Migration wizard (cloud ↔ self-hosted) | ✅ Full | Readiness check + shadow test + one-click cutover in both directions. | | Email branding + template overrides | ✅ Full | Same Settings → Integrations UI. | | Team invitations + role changes | ✅ Full | Admin auth always flows through the control plane. | ## What "Same binary" Actually Means * We build one Docker image per release: `knoxcall/proxy:{version}`. * That image runs on our servers at knoxcall.com AND on your self-hosted infrastructure. * The only difference is environment configuration: `KNOXCALL_DEPLOYMENT_MODE=cloud | self_hosted`, which tenant it serves, and which external credentials it's given. * No forked codebase, no "Enterprise Edition", no minus-features stripped out. When we ship a new feature on cloud, it arrives in the next self-hosted image pull. ## Self-Hosted is Not a Subset A common worry: "self-hosted versions of SaaS products are always stripped down." That's not our model. Self-hosted has **more** required configuration (you bring your own SMTP, Redis, Postgres, MaxMind, etc.) but the runtime behaviour is identical. What the customer loses in self-hosted is: * **The cloud operator doing ops for you** — you back up your own Postgres, rotate your own secrets, watch your own alerts. We still push the control-plane config (routes, secrets, feature flags) but the data plane is yours to operate. * **Cross-tenant features we haven't yet made portable** — Stripe billing, KnoxCall-branded egress IPs, the global AI anomaly model trained across tenants. These stay cloud-only. Everything else ships identical between the two. ## How We Enforce Parity Three mechanisms: 1. **Source identity.** Both modes compile from the same `src/`. CI rebuilds the image on every merge. 2. **Shadow testing** during migration. The migration wizard dual-sends real requests through both sides and diffs responses before letting you cut over. 3. **This document.** Every known divergence is listed. If you find a feature that behaves differently and isn't in the Cloud-only row, that's a bug — email us. See also: [Self-hosted proxy guide](/infrastructure/self-hosted-proxy), [Migration guide](/infrastructure/migration), [Agent security model](/security/agent-security-model). # Self-Hosted Proxy Source: https://docs.knoxcall.com/infrastructure/self-hosted-proxy Run the KnoxCall data plane on your own Docker infrastructure. Same binary as the cloud — your payloads never leave your network. # Self-Hosted Proxy Self-hosted mode runs the same `knoxcall/proxy` Docker image on your infrastructure. Live API traffic — request bodies, response bodies, injected secrets — stays entirely within your VPC. Only routing metadata, tenant config, and re-encrypted secrets flow from the control plane over TLS. Requires an **Enterprise** subscription with `byoAgentEnabled` (the same flag gates the Go Agent). Reach out if you need to pilot — we can provision a sandbox agent. ## Why Self-Host * **Data-residency regulation**: HIPAA, FedRAMP-adjacent, EU-residency, sovereign-cloud contracts that require payload data to stay on-prem. * **Air-gapped environments**: systems without outbound internet except to a single control-plane endpoint. * **Latency-sensitive workloads**: proxy sits in the same VPC as your apps and upstream APIs. * **Compliance audits**: every byte of customer data remains under your audit log. ## How It Works ```text theme={"dark"} ┌──────────────────────┐ HTTPS (session bundles, │ KnoxCall cloud │ analytics, heartbeat) │ (control plane) │ ◀─────────────────────────────┐ │ Admin UI, routes, │ │ │ secrets (encrypted) │ │ └──────────────────────┘ │ │ ┌──────────────────────────────────────────────────────┤ │ Your infrastructure │ │ │ │ ┌────────────┐ ┌───────────────┐ ┌──────────┐ │ │ │ Your app │───▶│ knoxcall/ │──▶│ Upstream │ │ │ │ │ │ proxy │ │ API │ │ │ │ │ │ (docker) │ │ │ │ │ └────────────┘ └───────────────┘ └──────────┘ │ │ │ │ │ ┌─────┴─────┐ │ │ │ Postgres │ (your DB) │ │ │ Redis │ │ │ └───────────┘ │ └─────────────────────────────────────────────────────┘ ``` * **Control plane** at knoxcall.com holds route config and MASTER\_KEY-wrapped secrets. * **Your container** pulls a signed [session bundle](#session-bundles) every hour. * **Request flow**: your app → `knoxcall/proxy` (your infra) → upstream API. Control-plane hop only happens during session-bundle refresh. ## Prerequisites 1. **Credentials**: create a server-mode agent under **Automation → Agents** with `mode: server`. Copy the `kc_agent_xxx` ID and secret (shown only once). 2. **Local Postgres + Redis**: one of each, reachable from the container. SQLite is not supported. 3. **Local MASTER\_KEY\_B64**: generate a 32-byte base64 key locally (`openssl rand -base64 32`). **Do NOT share this with KnoxCall**; it stays on your infrastructure and wraps integration credentials at rest. 4. **Outbound HTTPS** to `knoxcall.com` (for session-bundle refresh + metrics reporting). 5. **Public HTTPS endpoint** (typically `https://proxy.yourdomain.com`) behind your preferred TLS terminator. ## Install ### Docker Compose Before bringing anything up, generate two strong secrets and place them in a local `.env` file (never commit): ```bash theme={"dark"} # MASTER_KEY_B64 — stays on your infra; wraps integration credentials at rest openssl rand -base64 32 # POSTGRES_PASSWORD — database credential; any long random string openssl rand -base64 24 ``` Create `.env` alongside the compose file: ```ini theme={"dark"} KNOXCALL_TENANT_ID= KNOXCALL_AGENT_ID=kc_agent_xxx KNOXCALL_AGENT_SECRET= MASTER_KEY_B64= POSTGRES_PASSWORD= ``` Then the compose file: ```yaml theme={"dark"} services: proxy: image: knoxcall/proxy:latest restart: unless-stopped ports: ["8080:8080"] environment: KNOXCALL_DEPLOYMENT_MODE: self_hosted KNOXCALL_TENANT_ID: ${KNOXCALL_TENANT_ID} KNOXCALL_AGENT_ID: ${KNOXCALL_AGENT_ID} KNOXCALL_AGENT_SECRET: ${KNOXCALL_AGENT_SECRET} KNOXCALL_CONTROL_PLANE_URL: https://knoxcall.com PG_URL: postgres://kc:${POSTGRES_PASSWORD}@postgres:5432/kc MASTER_KEY_B64: ${MASTER_KEY_B64} REDIS_URL: redis://redis:6379 depends_on: [postgres, redis] postgres: image: postgres:16 environment: POSTGRES_USER: kc POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: kc volumes: ["./pgdata:/var/lib/postgresql/data"] redis: image: redis:7-alpine ``` `docker compose up -d`, then point your TLS terminator (Caddy, Nginx, Cloudflare Tunnel, ALB) at `:8080`. ### Health Checks Once running, the container exposes: * `GET /healthz` — readiness probe. Returns **503** until the first control-plane sync succeeds, then **200** with sync stats. Returns **503** again if the bundle goes stale (control plane unreachable for >1 hour). * `GET /livez` — liveness probe. Always **200** while the process is responsive. Point your orchestrator's readiness probe at `/healthz` so a container with a dead control-plane link is depooled automatically. ## Session Bundles Every hour the container POSTs to `/agent/v1/session` with its agent credentials and receives a bundle containing: * **Routes** merged with their production-environment config * **Secrets** re-encrypted with an HKDF-derived session key (decrypts locally) * **Tenant metadata** (slug) * **Environments** and **API keys** **The agent must be registered in `server` mode to receive any of this.** The bundle is shaped by the registration's mode: a `server` registration (this container) gets the list above; an `intercept` registration — the on-host MITM client — gets its routes, the data-plane URL and its database-proxy listeners and **no secrets, no PKI material, no transit keys and no dynamic database credentials at all**. A registration whose mode is anything else gets the narrow shape too, so an omitted or unrecognised mode fails safe rather than handing out custody. If your proxy starts but every route reports a missing secret, check the agent's mode on its detail page. Switching an existing agent to proxy mode from that page sets it; so does creating the agent with `mode: server`. The session key never leaves the container. The MASTER\_KEY\_B64 on the control plane is never sent — secrets are re-encrypted per-window so even a packet capture during refresh yields nothing useful past the current hour. ### Field actions are not available on a self-hosted proxy A route configured with **Relay field actions** (`tokenize`, `detokenize`, `encrypt`, `decrypt` on selected JSON fields) is **refused** by a self-hosted container, with `503` and `"code": "relay_unavailable"`. It is not served with the transform skipped. Field actions resolve against the vault and ECDH key material held by the control plane, so a container cannot execute one. Forwarding the request anyway would send the exact value the action exists to remove — a card number, a national ID — upstream in the clear, and answer the upstream's normal `200`. Refusing is the only safe answer, and each refusal writes an audit row and pages KnoxCall operations. If you need field actions on a route today, serve that route from the KnoxCall cloud data plane. Routes without field actions are unaffected. Adding a field action to a route that a self-hosted container serves takes that route **out of service** on that container from the next hourly bundle refresh. Remove the action to restore it. ## Local Integrations The control plane **never pushes KnoxCall's own integration credentials** (SMTP, Twilio, S3, Anthropic) to a self-hosted container. You configure your own under **Settings → Integrations**. Required minimum for a functional self-hosted deployment: * **Email provider** — otherwise alerts, invitations, and password resets fail silently. Optional: * **S3** — for response-body archival * **Anthropic** — for AI anomaly detection on your alerts * **Twilio** — for SMS alerts ## Tamper Seal Every `knoxcall/proxy` image is built with a two-pass `build_sig`: 1. Compile the image with a placeholder signature. 2. Hash the compiled `dist/` contents. 3. HMAC-SHA256 of `hash:version` with a CI-only secret. 4. Rebuild with the real signature baked in. The control plane validates this on every session refresh. In strict mode (`KNOXCALL_STRICT_TAMPER_CHECK=true`), an unknown signature = session refused. Default mode logs a tamper event but still issues the session so you have time to investigate. ## Who can create a server-mode agent A **server-mode** agent's secret is traded for this workspace's whole custody set (every secret the proxy may inject), so handing one out is a reveal. These four dashboard actions are covered: * creating an agent with `mode: "server"`; * rotating the secret of a server-mode agent; * creating an install token (the first step of `install.sh`); * switching an agent to proxy mode (`switch_to_proxy`, or `switch_mode` to `proxy`), which makes its next session a server-mode one. In the dashboard this is a **Class B reveal**: only an **Owner** or an **Admin** can do it (an Editor cannot, even with **Reveal secrets** turned on; that setting covers secret values, certificates and credentialed workflow tests only), and every use asks for a fresh second factor (an authenticator code, a passkey or a security key; an emailed code does not count). Each use writes a `plaintext.reveal` entry to the audit log **before** anything is shown, and counts against the same per-person reveal budget as showing a secret's value (60 per 5 minutes). If the audit entry cannot be written, or the budget cannot be counted, the request is refused rather than served. The Management API (`/v1`) cannot create a server-mode agent; it is created in the dashboard. Creating or rotating an **intercept** agent, and switching an agent back to intercept or control, are not reveals and are unchanged. The Owner is still emailed whenever an agent credential is minted. ## Next Steps * Read the [migration guide](/infrastructure/migration) to cutover from cloud to self-hosted with zero downtime * Configure [email and other integrations](/infrastructure/self-hosted-proxy#local-integrations) on your self-hosted container * Run [shadow-mode tests](/infrastructure/migration#shadow-testing) before flipping live traffic # Make Source: https://docs.knoxcall.com/integrations/make Use KnoxCall in Make (Integromat) scenarios The KnoxCall Make app brings KnoxCall into your visual scenarios, with instant triggers and the security-forward actions. ## Create a connection 1. Add a KnoxCall module to a scenario and create a new connection. 2. Choose your **Environment** (Production or Sandbox). 3. Enter the **Client ID** and **Client Secret** from KnoxCall → **Security → API credentials**. The connection performs the OAuth client-credentials exchange, stores the token, and labels itself with your tenant. Make refreshes the token automatically. ## Triggers (instant) * **Watch Request Events** — one trigger, multi-select the lifecycle events you care about (success, error, 4xx, 5xx, timeout, …), optionally filtered to a route. * **Watch Audit Events (SIEM)** — every audit-log entry, for streaming into a SIEM or notification channel. Both are webhook (REST-hook) triggers: Make registers a webhook on enable and removes it on disable. Deliveries are HMAC-signed. ## Actions * **Proxy a Request** — call any API through KnoxCall with `{{token: ...}}` placeholders. * **Tokenize / Detokenize**, **Encrypt / Decrypt / Sign / Sign JWT**. * **Issue / Revoke Database Credential**, **Issue / Revoke Certificate**, **Rotate OAuth Client Secret**, **Create Secret**. * Plus full CRUD across routes, secrets, vaults, crypto keys, PKI, dynamic DB credentials, webhooks, clients, environments, OAuth clients, API keys, and agents. ## Searches Find Route / Get Route, Find / Get Secret, Get OAuth2 Secret Token, Get Crypto Public Key, List Vaults & Tokens, List DB connections/roles/leases, List PKI roots / Get CRL, List Audit Logs, Get Account / Usage, List Webhooks & logs, List Event Types, and more. The Make app covers the full KnoxCall module catalog. # n8n Source: https://docs.knoxcall.com/integrations/n8n Use KnoxCall in n8n workflows `n8n-nodes-knoxcall` is a community node for [n8n](https://n8n.io/) — a great fit for technical and security-minded teams running self-hosted automation. ## Install In n8n, go to **Settings → Community Nodes → Install** and enter: ``` n8n-nodes-knoxcall ``` Self-hosters can also `npm install n8n-nodes-knoxcall` in their n8n custom-nodes directory. ## Credentials Create a **KnoxCall API** credential: choose your **Environment** (Production or Sandbox) and paste the **Client ID** and **Client Secret** from KnoxCall → **Security → API credentials**. The credential performs the OAuth client-credentials exchange; its test calls `GET /v1/account`. ## Nodes **KnoxCall** (action node) — pick a resource and operation: * **Proxy** → Send Request (inject secrets with `{{token: ...}}`). * **Vault** → Tokenize, Detokenize. * **Crypto Key** → Encrypt, Decrypt, Sign, Sign JWT, Get Public Key. * **Database Credential** → Issue, Revoke. * **Secret** → Create, Get OAuth2 Token. * **OAuth Client** → Rotate Secret. * **Certificate (PKI)** → Issue, Revoke. * **Route / Audit Log / Account / Webhook** → list/get reads. * **Environment / Client / API Key / Agent** → create and list management resources. **KnoxCall Trigger** (trigger node) — starts a workflow on KnoxCall events. One multi-select **Events** field covers all `request.*` events plus `audit.event`; optional route filter; HMAC signature verification built in. **KnoxCall Polling Trigger** (trigger node) — polls for New Audit Log, New Route, New Secret, New Database Lease, and New Webhook Delivery. The n8n node covers the full KnoxCall module catalog. # Automation Platforms Source: https://docs.knoxcall.com/integrations/overview Connect KnoxCall to Zapier, Make, and n8n KnoxCall has native connectors for the major automation platforms, so non-developer teams can wire KnoxCall into thousands of apps without writing code. 5,000+ apps. Comprehensive coverage across every KnoxCall resource. Visual scenarios. Full module coverage across every KnoxCall resource. Self-hostable, open-source. Community node with full module coverage. ## What makes these connectors different Most "secrets" integrations just read and write values. KnoxCall's connectors are built so the secret never has to enter the automation platform: * **Proxy a Request** — call any third-party API *through* KnoxCall. Reference stored secrets and vault tokens with `{{token: ...}}` placeholders; the real values are injected server-side. Your API keys never touch Zapier/Make/n8n. * **Tokenize / Detokenize** — swap PII (card numbers, SSNs, emails) for vault tokens so raw values never sit in an automation's data store. * **Encrypt / Decrypt / Sign / JWT** — encryption-as-a-service from a single step. * **Issue short-lived DB credentials** and **certificates** on demand. ## Authentication (all platforms) Every connector authenticates with **OAuth client credentials**. In KnoxCall, go to **Security → API credentials** and create an OAuth client; copy its **Client ID** and **Client Secret** into the connector along with your **Environment** (Production or Sandbox). The connector exchanges them for a short-lived token and auto-discovers your tenant — you never type your slug. DPoP (sender-constrained tokens) is not used by these connectors. If your OAuth client requires DPoP, the connector will tell you — disable DPoP on that client or use a different one. ## Triggers Triggers are powered by KnoxCall webhooks. Subscribe to the request lifecycle (`request.success`, `request.error`, `request.client_error`, …) or to `audit.event` to stream every audit-log entry into Slack, a SIEM, or a spreadsheet. Deliveries are HMAC-signed; the connectors verify the signature for you. # Anthropic Source: https://docs.knoxcall.com/integrations/setup/anthropic Bring your own Anthropic API key to KnoxCall — what the workspace-scoped key powers (anomaly analysis, spec analysis, the workflow copilot), how to scope it with a Workspace and spend limit, and the difference from AI Gateway upstream keys. # Anthropic KnoxCall's AI features run on Claude. Which key they use depends on the scope: | Scope | What the key powers | | - | - | | **This tenant** | Everything AI that KnoxCall does *for your workspace*: alert anomaly analysis, OpenAPI spec analysis, collection analysis, the workflow copilot, and the AI Gateway's system agent. **There is no fallback to a platform key** — until you save your own, those features tell you a key is required. Your usage is billed to your Anthropic account, never to KnoxCall's. | | **Global defaults** | The platform's own AI: the support assistant, blog autopilot and AI-visibility probes. On KnoxCall Cloud this is KnoxCall's key; a self-hosted deployment that wants those features saves its own here. | This is **not** where the AI Gateway gets keys for the models it proxies. Upstream provider keys for the gateway are attached to the gateway itself — see [Bring your own key](/ai-gateway/byo-key). ## 1. In the Anthropic Console Anthropic API keys carry no scopes; you scope them by **Workspace**, which also isolates rate limits and spend. 1. [console.anthropic.com](https://console.anthropic.com) → **Settings → Workspaces → Create workspace**, dedicated to KnoxCall. 2. On that workspace set a **monthly spend limit** — the key is used by unattended analysis, not only by people clicking buttons. 3. **Settings → API keys → Create Key**, attached to that workspace. Copy the value (`sk-ant-api03-…`) — shown once. ## 2. Enter it in KnoxCall **Settings → Integrations** → scope → **Anthropic** → *Configure*: | Field | Value | | - | - | | **API key** | `sk-ant-…` | | **Model** *(optional)* | Leave blank. KnoxCall picks a current model centrally and moves it forward when a new one ships; set this only to pin a specific model id. | **Save credentials.** ## 3. Verify Open an alert and run **AI analysis**, or ask the workflow copilot for a suggestion. A wrong key fails there with an authentication error from Anthropic. The operator console's *Test connection* lists models with the stored key. ## Things that bite * **Rotation.** Create the new key, save it here, then delete the old one in the console. The form keeps the stored key when the box is left blank, so a public-only edit (changing the model) never drops it. * **Self-hosted environment fallback.** For the Global defaults row, a stored key wins over `ANTHROPIC_API_KEY`; the variable is used only when no row exists. * **Spend limits are yours to set.** KnoxCall applies its own per-workspace AI rate limits, but the bill is on your account. ## Related * [AI threat detection](/security/ai-threat-detection) — what anomaly analysis looks for * [Workflows — AI](/workflows/ai) — the copilot and AI steps * [OpenAI](/integrations/setup/openai) — the platform fallback provider # AWS Operator Identity (BYOK) Source: https://docs.knoxcall.com/integrations/setup/aws-operator-identity The AWS identity KnoxCall uses to assume customer KMS roles for Bring-Your-Own-Key — an IAM user with sts:AssumeRole scoped to BYOK role ARNs and nothing else, and how it differs from the master-key unseal identity. # AWS Operator Identity (BYOK) When a workspace brings its own AWS KMS key ([tenant KMS setup](/essentials/tenant-kms/setup-aws)), KnoxCall does not hold that workspace's AWS credentials. It holds **one identity of its own** and assumes the role the workspace created, and the workspace's key policy trusts exactly that identity. This card is that identity. **Scope: Global defaults** only. On KnoxCall Cloud it is KnoxCall's; the BYOK wizard shows workspaces the principal to trust. A self-hosted deployment that offers BYOK to its workspaces configures its own here. This is **not** the master-key unseal identity. Rotating or deleting it cannot lock the deployment out of its master key; it affects only workspaces' BYOK operations. ## 1. In AWS IAM 1. **IAM → Users → Create user**, no console access. 2. Attach an inline policy with exactly two actions: ```json theme={"dark"} { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": "sts:GetCallerIdentity", "Resource": "*" }, { "Effect": "Allow", "Action": "sts:AssumeRole", "Resource": "arn:aws:iam::*:role/knoxcall-byok-*" } ] } ``` `GetCallerIdentity` lets KnoxCall display which principal workspaces should trust. `AssumeRole` is restricted by resource to the BYOK role naming pattern — never `*`. **No `kms:*` on this identity**: KMS permissions are granted by each workspace to the *assumed* role, which is the whole point of BYOK. 3. **Security credentials → Create access key → Application running outside AWS**. Copy both halves. ## 2. Enter it in KnoxCall **Settings → Integrations → Global defaults → AWS operator identity (BYOK)** → *Configure*: | Field | Value | | - | - | | **Access key ID** | `AKIA…` | | **Secret access key** | The secret half | | **Default region** *(optional)* | e.g. `us-east-1` | **Save credentials.** ## 3. Verify Open a workspace's **Settings → Security → Tenant master key** and start the AWS BYOK wizard: it shows the resolved principal (a `GetCallerIdentity` call with this identity). Completing a BYOK setup end-to-end proves `AssumeRole`. ## Things that bite * **When no row is stored, the AWS SDK default chain is used** (environment, shared config, instance metadata) — so "not configured" does not mean "no AWS identity in play" on a self-hosted host. * **Rotation is two-sided only in one direction.** Workspaces trust the *user's ARN*, not its key, so rotating the access key needs no change on their side; deleting the user does. ## Related * [BYOK and KMS](/essentials/key-management/byok-and-kms) * [Tenant KMS — AWS setup](/essentials/tenant-kms/setup-aws) # Chatwoot (Support Bot) Source: https://docs.knoxcall.com/integrations/setup/chatwoot Connect Chatwoot to KnoxCall for the support widget and the assistant's proactive messages — a dedicated agent account, its API access token and your Chatwoot base URL. # Chatwoot (Support Bot) Backs the in-app support widget and the messages the assistant posts into conversations. **Scope: Global defaults** only — a platform operator feature. A self-hosted deployment that does not run its own Chatwoot can leave it unconfigured; the widget stays off. ## 1. In Chatwoot 1. Create (or pick) a **dedicated agent account** for the integration and add it to the support inbox. An agent token inherits that agent's access, so do not use your own account, and give the agent only that inbox. 2. Sign in as that agent → **Profile settings** → copy the **Access token**. 3. Note your Chatwoot base URL: `https://app.chatwoot.com`, or your own host if self-hosted. ## 2. Enter it in KnoxCall **Settings → Integrations → Global defaults → Chatwoot (support bot)** → *Configure*: | Field | Value | | - | - | | **Access token** | The agent's API access token | | **Base URL** | e.g. `https://support.your-domain.com` | **Save credentials.** ## 3. Verify Open the support widget in the app and send a message; it should appear in the inbox as a conversation. ## Things that bite * **Rotating the agent's token invalidates the old one immediately.** * **Environment fallback**: `CHATWOOT_*` variables are used on a self-hosted host when no token is stored, so an empty row does not prove the widget is off. ## Related * [Support chat](/support/support-chat) # Microsoft Clarity Source: https://docs.knoxcall.com/integrations/setup/clarity Give the operator console read access to Microsoft Clarity insights — generate a data-export API token on the project and enter it, mindful of Clarity's ten-requests-a-day quota. # Microsoft Clarity Powers the operator console's Clarity insights card — sessions, engagement, dead and rage clicks on the marketing site. **Scope: Global defaults** only — a platform operator feature. ## 1. In Clarity 1. [clarity.microsoft.com](https://clarity.microsoft.com) → open the project → **Settings → Data export → Generate new API token**. You must be an **Admin** on the project. 2. Name it for the console so it can be revoked independently, and copy it. 3. The project id is the segment in the project URL — useful context, not needed by the API. ## 2. Enter it in KnoxCall **Settings → Integrations → Global defaults → Microsoft Clarity** → *Configure*: | Field | Value | | - | - | | **Data-export API token** | The token | | **Clarity project ID** *(display only)* | From the project URL | **Save credentials.** ## 3. Verify The operator console's *Test connection* makes one minimal live-insights call. **It spends one of the day's requests.** ## Things that bite * **Hard quota: roughly ten export requests per project per day.** Every test click and every uncached page load burns one, which is why the console caches aggressively. * **Aggregated only.** Clarity returns insights over a one-to-three-day window; live visitor counts come from [GA4](/integrations/setup/google-analytics). * **No environment fallback exists for this type.** # DigitalOcean (Static-IP Egress) Source: https://docs.knoxcall.com/integrations/setup/digitalocean The DigitalOcean API token KnoxCall uses to create static-IP egress servers — custom token scopes, the project and control-plane tag fields, and why a tag beats an IP list. # DigitalOcean (Static-IP Egress) [Egress routing](/infrastructure/egress-routing) can give a route a fixed public address your upstream can allowlist. KnoxCall provisions the droplet, reserved IP and firewall for that on a DigitalOcean account — this card holds the token it does it with. **Scope: Global defaults** only. On KnoxCall Cloud this is KnoxCall's account. A self-hosted deployment that wants static-IP egress uses its own DigitalOcean account here. ## 1. In DigitalOcean 1. **API → Tokens → Generate New Token**. 2. **Custom Scopes** — enough is: `droplet` create/read/delete · `reserved_ip` create/read/update · `firewall` create/read/update · `project` read/update. A legacy *Read + Write* token also works but grants the whole account. 3. Pick an **expiry** and diary it: an expired token fails at provisioning time, not at save time. 4. Copy the token (`dop_v1_…`) — shown once. 5. Optional but recommended: **tag the droplet that runs KnoxCall** (e.g. `knoxcall-control-plane`). The egress firewall admits port 8443 only from droplets carrying that tag — the tag *is* the allowlist, so do not reuse it for anything else. ## 2. Enter it in KnoxCall **Settings → Integrations → Global defaults → DigitalOcean** → *Configure*: | Field | Value | | - | - | | **API token** | `dop_v1_…` | | **Project ID** *(optional)* | Groups the created resources under one DigitalOcean project | | **Control-plane droplet tag** | The tag from step 5. Preferred over IPs — it survives the control-plane address changing. | | **Control-plane IPs (fallback)** | Comma-separated, only when KnoxCall does not run on a droplet in this account. If the address changes, every egress server silently stops accepting connections. | **Save credentials.** ## 3. Verify Provision one egress server from **Egress**, confirm it comes up with a reserved IP, route a test request through it, then delete it. A bad token fails on the firewall listing call first. ## Things that bite * **Prefer the tag.** A stale IP list is the failure mode that looks like a healthy egress server refusing every connection. * **No control-plane source, no egress server.** With a token but neither a tag nor an IP list, every static IP request is refused before anything is created — `409` with `reason: "misconfigured"` and a message naming the field to set. The **Request Static IP** dialog checks the same thing when it opens and disables the request until it is fixed. * **Environment fallback.** With no stored token a self-hosted host falls back to `DO_API_TOKEN`, so a deleted row does not necessarily disable provisioning. * **Costs are yours**: each egress server is a droplet plus a reserved IP on your account. ## Related * [Egress routing](/infrastructure/egress-routing) # Email Source: https://docs.knoxcall.com/integrations/setup/email Connect the email provider behind every KnoxCall email — Resend, SendGrid, Postmark, Mailgun, Amazon SES or any SMTP host — with the least-privilege credential for each, the fields to fill in, and how to prove mail is flowing. # Email One provider sends **every** platform email: verification and magic-link sign-in, password resets, invitations, alert notifications, operator notifications and — on the hosted service — the blog newsletter. A broken row here silences all of it, so this is the first integration a self-hosted deployment should configure. **Scope: Global defaults.** The Email card only appears under **Global defaults**, because the mailer is platform-wide: on KnoxCall Cloud it is KnoxCall's provider, and on a self-hosted deployment it is yours. Workspaces do not configure an email provider — a workspace proves which addresses it may send *as* under **Settings → Sending domains**, and the platform provider does the sending. The detail view has three tabs: **Credentials** (this guide), **Branding** (logo and colours on the templates) and **Templates** (the wording). **Before you start** * Admin access to a provider account. * A sending domain you control, verified at the provider with **SPF and DKIM**. An unverified domain lands in spam or is refused outright; every provider below will show you the DNS records to publish. ## 1. Pick a provider and mint the credential Pick one. Each section below gives the least-privilege credential and the KnoxCall fields it fills. ### Resend The default provider on KnoxCall Cloud, and the only one with newsletter and engagement features. 1. [resend.com](https://resend.com) → **Domains → Add domain**, publish the DNS records, wait for *Verified*. 2. **API Keys → Create API key**. Permission **Sending access**, restricted to that domain. *Full access* is only needed if KnoxCall should also manage audiences (the newsletter features below). 3. Copy the key (`re_…`) — shown once. | KnoxCall field | Value | | - | - | | **API key** | `re_…` | | **From address** | An address on the verified domain, e.g. `noreply@your-domain.com` | | **From name** | Shown as the sender, e.g. your product name | | **Admin notification email** | The inbox that receives internal operator alerts (new signups, partner applications) | | **Marketing webhook signing secret** *(optional)* | The signing secret of a Resend webhook pointed at `/webhooks/resend`, so unsubscribes and bounces flow back. Only for the newsletter. | | **Marketing segment ID**, **Blog topic ID**, **Newsletter from** *(optional)* | Newsletter settings. The feature stays **off** until both IDs are set, and the topic's default subscription **must be opt-in** or you will broadcast to people who never subscribed. | ### SendGrid 1. SendGrid → **Settings → Sender Authentication** → authenticate your domain (DNS records). 2. **Settings → API Keys → Create API Key** → **Restricted Access** → grant **Mail Send** only. 3. Copy the key (`SG.…`) — shown once. | KnoxCall field | Value | | - | - | | **API key** | `SG.…` | | **From address** / **From name** | On the authenticated domain | | **Admin notification email** | Operator inbox for internal alerts | ### Postmark 1. Postmark → **Sender Signatures / Domains** → add and verify the domain (DKIM + Return-Path). 2. Open the **Server** that should send → **API Tokens** → copy a **Server API token**. Not the *Account* token — that manages servers and cannot send. | KnoxCall field | Value | | - | - | | **Server token** | The server API token | | **From address** / **From name** | On the verified domain | | **Message stream** | Usually `outbound` (the default transactional stream) | | **Admin notification email** | Operator inbox for internal alerts | ### Mailgun 1. Mailgun → **Sending → Domains → Add New Domain** (e.g. `mg.your-domain.com`), publish the DNS records, wait for verification. Note the **region** the domain was created in. 2. **API Security → Add new key** (a sending key scoped to the domain is enough). Copy it — shown once. | KnoxCall field | Value | | - | - | | **API key** | The sending key | | **Mailgun domain** | `mg.your-domain.com` | | **Region** | `us` (default) or `eu` — must match where the domain lives, or every send fails with a 401 | | **From address** / **From name** | On the Mailgun domain | | **Admin notification email** | Operator inbox for internal alerts | ### Amazon SES 1. SES console → **Verified identities → Create identity** → domain, with **Easy DKIM**. Publish the records and wait for *Verified*. If the account is still in the SES **sandbox**, request production access — the sandbox can only send to verified recipients. 2. IAM → **Users → Create user** (no console access) with an inline policy allowing only `ses:SendEmail` and `ses:SendRawEmail`. **Security credentials → Create access key → Application running outside AWS**; copy both halves. | KnoxCall field | Value | | - | - | | **AWS access key ID** / **AWS secret access key** | The IAM user's key pair | | **AWS region** | The region of the verified identity, e.g. `us-east-1` | | **From address** | Must be on the verified identity | | **From name** | Sender name | | **Configuration set** *(optional)* | An SES configuration set for event tracking | | **Admin notification email** | Operator inbox for internal alerts | ### Custom SMTP Any host that accepts authenticated submission — Google Workspace, Microsoft 365, Fastmail, a relay of your own. 1. Create a **dedicated mailbox or app password** for KnoxCall that is allowed to send as your from-address. Do not reuse a person's password. 2. Note the host, port and TLS mode: port **587 with STARTTLS** is the usual answer; **465** for implicit TLS. | KnoxCall field | Value | | - | - | | **Username** / **Password** | The submission account | | **Host** | e.g. `smtp.example.com` | | **Port** | `587` (STARTTLS) or `465` (implicit TLS) | | **Use TLS (true/false)** | `true` for 465; `false` for 587, where TLS is negotiated with STARTTLS | | **From address** / **From name** | An address the account may send as | | **Admin notification email** | Operator inbox for internal alerts | ## 2. Enter it in KnoxCall 1. **Settings → Integrations → Global defaults → Email** → *Configure*. 2. Choose the **Provider** at the top. The fields below change to match the tables above. 3. Fill them in and **Save credentials**. Switching provider later resets the credential fields — the old provider's secret is not carried over — but shared settings such as the from-address and the admin notification email are kept. ## 3. Verify Send yourself something real: **Team → Invite** a spare address, or sign out and request a magic link. Check both the inbox and the spam folder. A self-hosted deployment also shows a setup warning on the dashboard until a working email provider exists; that warning is answered by the same resolution the mailer uses, so it cannot say "configured" while sends are failing. ## Things that bite * **Required fields are enforced at send time, not save time.** Each provider needs its own set (Mailgun needs the domain, SES the region, SMTP the host); a row missing one saves cleanly and fails on the first real send. Send that test email. * **The from-address must be on the verified domain.** Providers reject or silently drop anything else. * **Resend engagement tracking rewrites links.** Open and click tracking are per-*domain* settings in Resend; when click tracking is on, every link in every message from that domain — password-reset and magic-link URLs included — is rewritten through Resend's redirect domain. If that is not acceptable, send credential mail from a second Resend domain with tracking off. * **Corporate link scanners register as clicks.** A recorded click is not proof a human clicked. * **Workspace sends.** Workflow email steps and alert notifications ride this provider too. A workspace may only send from a domain it has verified under **Settings → Sending domains**, or from a subdomain beneath one — never from a parent domain, and never from a KnoxCall-owned domain. ## Related * [Alerts](/monitoring/alerts) — email as an alert destination * [Workflows](/workflows/workflows-overview) — the email step # Google Analytics (GA4) Source: https://docs.knoxcall.com/integrations/setup/google-analytics Give the operator console read access to your GA4 property — enable the Data API, create a service account with a JSON key, grant it Viewer on the property, and enter the key and the numeric property ID. # Google Analytics (GA4) Powers the operator console's web-analytics page — live visitors, sessions, top pages and referrers — pulled from the GA4 property that tracks your marketing site. **Scope: Global defaults** only — a platform operator feature. Leave it unconfigured if you do not run the console. ## 1. In Google Cloud and GA4 1. **Google Cloud Console → APIs & Services → Library** → enable **Google Analytics Data API**. 2. **IAM & Admin → Service Accounts → Create service account**. Grant it **no** project roles at the project step — none are used. 3. Open the service account → **Keys → Add key → Create new key → JSON**. The file downloads once. 4. **GA4 → Admin → Property Access Management → Add users** → the service account's email (`…@….iam.gserviceaccount.com`) with the **Viewer** role. That is the only grant it needs. 5. **GA4 → Admin → Property Settings** → copy the **numeric Property ID** (not the `G-XXXXXXX` measurement ID). ## 2. Enter it in KnoxCall **Settings → Integrations → Global defaults → Google Analytics (GA4)** → *Configure*: | Field | Value | | - | - | | **Service account JSON key** | The entire contents of the JSON file, pasted verbatim — including the newlines inside `private_key` | | **GA4 property ID (numeric)** | e.g. `123456789` | **Save credentials.** ## 3. Verify The operator console's *Test connection* performs a token exchange and a minimal one-day report against the property, and reports a bad property id distinctly from a bad key. The web-analytics page then populates. ## Things that bite * **Numeric property ID, not the measurement ID.** * **Paste the whole file.** A JSON key with mangled newlines fails as an authentication error, not a parse error. * **No environment fallback exists for this type**; an unset row simply means the page has no data. * **Viewer at the account level works too**, but is broader than needed. # Google Places Source: https://docs.knoxcall.com/integrations/setup/google-places The Google Maps Platform key behind address autocomplete on KnoxCall's signup and address forms — enable the Places API, restrict the key to your hostnames, and understand that this key is served to browsers by design. # Google Places Address autocomplete on the signup and billing-address forms. **This key is delivered to every browser that loads those forms** — that is how the Places JavaScript library works — so it is public by design, and the restrictions in step 1 are the only thing between it and someone else's billing. **Scope: Global defaults** only. ## 1. In Google Cloud 1. **APIs & Services → Library** → enable the **Places API** (and **Maps JavaScript API** if your forms load the JS widget). 2. **Credentials → Create credentials → API key**. 3. **Edit the key immediately**: * **Application restrictions → Websites** → add every hostname that renders the forms, including staging. * **API restrictions → Restrict key** → select only the Places API (and Maps JavaScript API if enabled). 4. Set a **budget alert** on the project. 5. Copy the key (`AIza…`). ## 2. Enter it in KnoxCall **Settings → Integrations → Global defaults → Google Places** → *Configure*: | Field | Value | | - | - | | **API key** | `AIza…` | **Save credentials.** The key is stored encrypted like every other secret, and served to the forms at load time. ## 3. Verify Open the signup address field and type three characters — suggestions mean the key is live and the referrer is allowed. No suggestions with no error usually means a referrer restriction that does not include this host. ## Things that bite * **An unrestricted key here is a billing-theft vulnerability, not a theoretical one.** Restrict it before saving it. * **Every host that renders the form must be in the referrer list**, or autocomplete silently returns nothing there. * **Environment fallback**: `GOOGLE_PLACES_API_KEY` is used on a self-hosted host when no row is stored. # Migration Signing Key Source: https://docs.knoxcall.com/integrations/setup/migration-signing-key The RSA private key KnoxCall signs Workload Identity Federation assertions with during cross-cloud secret-store migrations — generate it, base64-encode the PEM, store it, and restart the API workers. # Migration Signing Key During a [secret-store migration](/essentials/migrations/overview) from Azure Key Vault or Google Secret Manager, KnoxCall presents signed assertions to the customer's cloud for **Workload Identity Federation**, and publishes the public half at its JWKS endpoint. This card holds the private key. **Scope: Global defaults** only. There is no provider — you generate the key yourself. It is a signing identity: anyone holding it can mint assertions that federate as your KnoxCall deployment. ## 1. Generate the key ```bash theme={"dark"} openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out migration-signing.pem base64 -w0 migration-signing.pem # macOS: base64 -i migration-signing.pem | tr -d '\n' ``` The second command prints the PKCS#8 PEM as a single base64 line. ## 2. Enter it in KnoxCall **Settings → Integrations → Global defaults → Migration signing key** → *Configure*: | Field | Value | | - | - | | **Signing key (base64 PEM)** | The single-line base64 output | **Save credentials**, then delete the local PEM file — the encrypted store is now the copy of record. ## 3. Restart, then verify The key is **read once at startup**. Restart the API workers, then fetch the JWKS endpoint: it serves the derived public key once a valid key has loaded. ## Things that bite * **Saving a new key does not take effect until the API workers restart.** * **A present-but-unreadable row is logged loudly and then ignored**, falling back to `MIGRATION_SIGNING_KEY` from the environment if set. Check the logs rather than assuming the new key took. * **Rotating invalidates any assertion a relying party has pinned** — coordinate with the migration counterparty before you do. ## Related * [Secret store migrations](/essentials/migrations/overview) # GitHub Sign-In Source: https://docs.knoxcall.com/integrations/setup/oauth-github Create a GitHub OAuth App for KnoxCall sign-in — organization-owned, one authorization callback URL, a generated client secret, and the client ID and secret to paste into Settings → Integrations. # GitHub Sign-In Adds a **Continue with GitHub** button to the login page, backed by an OAuth App you own. Read [OAuth Sign-In Methods](/integrations/setup/oauth-sign-in) first — it explains when you need your own app (self-hosted deployments do; KnoxCall Cloud does not) and which scope to save under. **Before you start** * Owner access to the GitHub **organization** that should own the app. You *can* create the app under a personal account, but an app owned by a person dies with that person's account. * The **Authorized redirect URI** from the KnoxCall card — **Settings → Integrations → OAuth Sign-In Methods → GitHub**. Self-hosted: set `GITHUB_OAUTH_REDIRECT_URI` first, as described [here](/integrations/setup/oauth-sign-in#self-hosted-set-the-redirect-uri-in-the-server-environment). ## 1. Create the OAuth App Use an **OAuth App**, not a GitHub App: sign-in is the OAuth web flow, and a GitHub App adds installation and permission machinery KnoxCall does not use. 1. Organization → **Settings → Developer settings → OAuth Apps → New OAuth App**. (For a personal account: your profile → **Settings → Developer settings**.) 2. **Application name**: for example `KnoxCall`. **Homepage URL**: your KnoxCall URL. 3. **Authorization callback URL**: the value from the KnoxCall card: * KnoxCall Cloud: `https://auth.knoxcall.com/callback` * Self-hosted: `https:///auth/github/callback` (the value of `GITHUB_OAUTH_REDIRECT_URI`) 4. Leave **Enable Device Flow** off. **Register application**. 5. On the app page copy the **Client ID**, then **Generate a new client secret** and copy it — GitHub shows it once. ## 2. Enter it in KnoxCall **Settings → Integrations** → scope → **OAuth Sign-In Methods** → **GitHub** tab: | Field | Value | | - | - | | **Authorized redirect URI** | Read-only. The value you registered in step 1. | | **Client ID** | The app's Client ID. | | **Client secret** | The generated secret. Leave blank on a later edit to keep the stored one. | **Save.** ## 3. Verify Open the login page in a private window and click **Continue with GitHub**. GitHub asks the person to authorise the app for `read:user` and `user:email`; a first-time account is linked to an existing KnoxCall account with the same verified email, or created. ## Things that bite * **Exactly one callback URL per OAuth App.** Unlike Google, a GitHub OAuth App cannot hold both a staging and a production callback. Create one app per KnoxCall environment. * **Verified email required.** KnoxCall reads the person's email through `user:email` and only accepts a **verified** address. A GitHub account whose addresses are all unverified is refused with `error=unverified_email`; a private email is fine — the scope reads it even when it is hidden from the public profile. * **Organization-owned apps and third-party access restrictions.** If the organization restricts third-party OAuth access, an app owned by *that* organization is exempt; an app owned by a personal account may be blocked from reading membership, though sign-in itself does not need it. * **Rotating the secret.** Generating a new client secret does not revoke the old one until you delete it, so you can generate, save in KnoxCall, then delete the old secret with no gap. * **A stored row wins over environment variables.** Deleting the row on a self-hosted host that still carries `GITHUB_OAUTH_CLIENT_ID` / `_SECRET` falls back to those. ## Related * [OAuth Sign-In Methods](/integrations/setup/oauth-sign-in) — scopes, account matching, environment variables, troubleshooting * [Google](/integrations/setup/oauth-google) · [Microsoft Entra ID](/integrations/setup/oauth-microsoft) # Google Sign-In Source: https://docs.knoxcall.com/integrations/setup/oauth-google Create a Google OAuth client for KnoxCall — consent screen, OAuth 2.0 Client ID of type Web application, the authorized redirect URI, and the client ID and secret to paste into Settings → Integrations. # Google Sign-In Adds a **Continue with Google** button to the login page, backed by an OAuth client you own. Read [OAuth Sign-In Methods](/integrations/setup/oauth-sign-in) first if you have not — it explains when you need your own app (self-hosted deployments do; KnoxCall Cloud does not) and which scope to save under. **Before you start** * A Google Cloud project you can create credentials in (the *Owner* or *Editor* role, or *OAuth Config Editor*). * The **Authorized redirect URI** from the KnoxCall card — open **Settings → Integrations → OAuth Sign-In Methods → Google** and copy it. Self-hosted: set `GOOGLE_OAUTH_REDIRECT_URI` first, as described [here](/integrations/setup/oauth-sign-in#self-hosted-set-the-redirect-uri-in-the-server-environment), or the card shows the Cloud value. ## 1. Configure the consent screen You cannot create an OAuth client until the project has a consent screen. 1. Google Cloud Console → **APIs & Services → OAuth consent screen** (labelled *Google Auth Platform* in newer consoles). 2. **User type**: * **Internal** — only accounts in your Google Workspace organisation can sign in. Right for a company deployment; no verification needed. * **External** — any Google account. Starts in *Testing* mode, which only admits the test users you list; leaving Testing requires Google's app verification. 3. App name, support email and developer contact. Nothing else is required — KnoxCall only requests `openid`, `email` and `profile`, which are non-sensitive scopes. ## 2. Create the OAuth client 1. **APIs & Services → Credentials → Create credentials → OAuth client ID**. 2. **Application type: Web application**. Name it (for example `KnoxCall`). 3. Under **Authorized redirect URIs → Add URI**, paste the value from the KnoxCall card: * KnoxCall Cloud: `https://auth.knoxcall.com/callback` * Self-hosted: `https:///auth/google/callback` (the value of `GOOGLE_OAUTH_REDIRECT_URI`) *Authorized JavaScript origins* are not needed — the flow is server-side. 4. **Create**, then copy the **Client ID** (ends in `.apps.googleusercontent.com`) and the **Client secret**. Google shows the secret at creation and lets you download it later; treat the download like any other credential. No Google API needs enabling: sign-in uses the OpenID Connect userinfo endpoint, which the consent screen already covers. ## 3. Enter it in KnoxCall **Settings → Integrations** → scope → **OAuth Sign-In Methods** → **Google** tab: | Field | Value | | - | - | | **Authorized redirect URI** | Read-only. The value you registered in step 2. | | **Client ID** | The `….apps.googleusercontent.com` id. | | **Client secret** | The secret. Leave blank on a later edit to keep the stored one. | **Save.** The tab's dot turns green and the login page shows the button. ## 4. Verify Open the login page in a private window and click **Continue with Google**. A successful sign-in lands on the dashboard; a returning account is signed straight in, and a first-time account is linked to an existing KnoxCall account with the same verified email, or created. If Google shows **Error 400: redirect\_uri\_mismatch**, the URI in step 2 is not identical to the one in the card. The error page names the URI KnoxCall sent — register exactly that. ## Things that bite * **External + Testing mode.** Only listed test users can sign in (up to 100), and the consent screen shows an "unverified app" warning. Fine for a pilot; for everyone else, either switch to Internal (Workspace organisations) or complete verification. * **Internal is per organisation.** An Internal consent screen refuses accounts outside your Workspace domain, including personal Gmail accounts. That is a feature, not a bug — but it is why a contractor's login "does nothing". * **The secret is not stored on the Google side in a retrievable way once rotated.** If you *Reset secret* in Google, update KnoxCall immediately; the old secret stops working the moment the new one is created. * **One client can hold several redirect URIs.** A staging and a production KnoxCall can share one Google client — unlike GitHub, which allows exactly one callback URL per app. * **A stored row wins over environment variables.** Deleting the row on a self-hosted host that still carries `GOOGLE_OAUTH_CLIENT_ID` / `_SECRET` falls back to those, so the button does not disappear. ## Related * [OAuth Sign-In Methods](/integrations/setup/oauth-sign-in) — scopes, account matching, environment variables, troubleshooting * [Microsoft Entra ID](/integrations/setup/oauth-microsoft) · [GitHub](/integrations/setup/oauth-github) * [SAML with Google Workspace](/sso/google-workspace) — federated SSO instead of social sign-in # Microsoft Sign-In (Entra ID) Source: https://docs.knoxcall.com/integrations/setup/oauth-microsoft Create a Microsoft Entra ID app registration for KnoxCall sign-in — supported account types, the Web redirect URI, a client secret (Value, not Secret ID), User.Read, and the allowed-directory list that stops other Entra tenants signing in. # Microsoft Sign-In (Entra ID) Adds a **Continue with Microsoft** button to the login page, backed by an app registration you own in Microsoft Entra ID (formerly Azure AD). Read [OAuth Sign-In Methods](/integrations/setup/oauth-sign-in) first — it explains when you need your own app (self-hosted deployments do; KnoxCall Cloud does not) and which scope to save under. This is the OpenID Connect / OAuth object (an **App registration**). Enterprise SAML federation with Entra — claims, group mapping, token encryption, Single Logout — is a different object and a different guide: [SAML SSO with Microsoft Entra ID](/sso/entra-id). **Before you start** * Permission to create app registrations in your directory (*Application Developer* or better; *Application Administrator* if your directory restricts registration). * The **Authorized redirect URI** from the KnoxCall card — **Settings → Integrations → OAuth Sign-In Methods → Microsoft**. Self-hosted: set `MICROSOFT_OAUTH_REDIRECT_URI` first, as described [here](/integrations/setup/oauth-sign-in#self-hosted-set-the-redirect-uri-in-the-server-environment). * Your **Directory (tenant) ID** — you will want it for step 4. ## 1. Register the application 1. [Entra admin center](https://entra.microsoft.com) → **Identity → Applications → App registrations → New registration**. 2. **Name**: for example `KnoxCall`. 3. **Supported account types**: * **Accounts in this organizational directory only (single tenant)** — the safe default for a company deployment. Microsoft itself then refuses sign-ins from any other directory. * **Accounts in any organizational directory (multitenant)** — only if people from *other* organisations must sign in. If you pick this, the allowed-directory list in step 4 becomes essential. 4. **Redirect URI**: platform **Web**, value from the KnoxCall card: * KnoxCall Cloud: `https://auth.knoxcall.com/callback` * Self-hosted: `https:///auth/microsoft/callback` (the value of `MICROSOFT_OAUTH_REDIRECT_URI`) 5. **Register**. On the **Overview** page copy the **Application (client) ID** and the **Directory (tenant) ID**. ## 2. Create a client secret 1. The app → **Certificates & secrets → Client secrets → New client secret**. 2. Description and expiry. Entra allows at most 24 months; pick the longest your policy allows and **diary the expiry** — sign-in stops the day it lapses, with no warning from KnoxCall. 3. **Add**, then copy the **Value** column immediately. It is masked as soon as you leave the blade. Copy the **Value**, not the **Secret ID**. The Secret ID is a GUID that identifies the secret; it is not the secret. Pasting it into KnoxCall fails at sign-in with an *invalid client secret* error (`AADSTS7000215`), not at save time. ## 3. Check API permissions **API permissions** should show **Microsoft Graph → User.Read (Delegated)** — it is added by default on a new registration. That is the only permission KnoxCall uses. No application permissions, and no admin consent is needed beyond `User.Read` (users consent on first sign-in; you may grant admin consent for the directory to skip that prompt). ## 4. Enter it in KnoxCall **Settings → Integrations** → scope → **OAuth Sign-In Methods** → **Microsoft** tab: | Field | Value | | - | - | | **Authorized redirect URI** | Read-only. The value you registered in step 1. | | **Client ID** | The *Application (client) ID*. | | **Client secret** | The secret **Value** from step 2. Leave blank on a later edit to keep the stored one. | | **Allowed directory (tenant) IDs** | Your *Directory (tenant) ID* — see below. Comma-separated if more than one. | **Save.** ### Restrict sign-in to your directory With the allowed-directory list **empty**, KnoxCall uses Microsoft's `/common` endpoint, which accepts a sign-in from **any** Entra directory — including one anybody can create for free in minutes. The account still has to present a verified email, but "any directory" is rarely what you mean. Put your directory ID in the box. With exactly one ID, KnoxCall addresses that directory's endpoints directly, so Microsoft refuses every other directory before KnoxCall is even involved; with several, KnoxCall checks the directory claim on every sign-in and refuses the rest. Leave it empty only if you deliberately chose *multitenant* in step 1 and want that behaviour. On a self-hosted deployment `MICROSOFT_OAUTH_ALLOWED_TENANT_IDS` is the environment form of the same list. ## 5. Verify Open the login page in a private window and click **Continue with Microsoft**. Sign in with an account in your directory; a first-time account is linked to an existing KnoxCall account with the same verified email, or created. ## Things that bite * **`AADSTS50011`** — the redirect URI registered on the app is not the one KnoxCall sent. Compare byte for byte, including the platform: it must be **Web**, not *SPA* or *Mobile*. * **`AADSTS7000215`** — invalid client secret. Almost always the Secret ID was pasted instead of the Value, or the secret has expired. * **`AADSTS50020` / "user account does not exist in tenant"** — the person's account is in a directory the app does not admit (single-tenant registration, or the allowed-directory list). That is the control working. * **Secrets expire.** Rotate before the date: create a second secret, save it in KnoxCall, then delete the old one. There is no overlap problem — KnoxCall holds one secret and uses it from the moment you save. * **A stored row wins over environment variables.** Deleting the row on a self-hosted host that still carries `MICROSOFT_OAUTH_CLIENT_ID` / `_SECRET` falls back to those. ## Related * [OAuth Sign-In Methods](/integrations/setup/oauth-sign-in) — scopes, account matching, environment variables, troubleshooting * [Google](/integrations/setup/oauth-google) · [GitHub](/integrations/setup/oauth-github) * [SAML SSO with Microsoft Entra ID](/sso/entra-id) — federated SSO instead of social sign-in # OAuth Sign-In Methods Source: https://docs.knoxcall.com/integrations/setup/oauth-sign-in Let people sign in to KnoxCall with Google, Microsoft or GitHub using your own OAuth apps — the one redirect URI all three share, the scopes requested, which scope to save under, and the environment variables a self-hosted deployment uses. # OAuth Sign-In Methods The **OAuth Sign-In Methods** card on **Settings → Integrations** holds up to three OAuth apps — one per provider — and the login page shows a *Continue with …* button for each one that is configured and enabled. This page covers what is common to all three; each provider has its own walkthrough: [Google](/integrations/setup/oauth-google) · [Microsoft Entra ID](/integrations/setup/oauth-microsoft) · [GitHub](/integrations/setup/oauth-github) This is **social / OAuth sign-in**, where the provider proves an email address and KnoxCall matches it to an account. For federated enterprise SSO — SAML 2.0 with your identity provider, domain verification and Single Logout — see [Single Sign-On (SAML)](/sso/overview). ## Do you need your own apps? * **KnoxCall Cloud** already has Google, Microsoft and GitHub wired to KnoxCall's own OAuth apps. You do not need to configure anything for the buttons to work. * **Self-hosted deployments** must bring their own apps: KnoxCall's production apps only accept redirect URIs on `knoxcall.com`, so a deployment on `knox.example.com` cannot reuse them. Which scope to save under: | Deployment | Save under | Why | | - | - | - | | Self-hosted, one workspace | **This tenant** or **Global defaults** | With exactly one workspace holding a provider's app, KnoxCall uses it for every sign-in on that provider regardless of host. Global defaults works too and is the clearer choice when the operator is the admin. | | Self-hosted, several workspaces | **Global defaults** | One platform-wide app per provider. | | KnoxCall Cloud | — | Sign-in already works with KnoxCall's apps. A workspace-scoped app is not supported at sign-in on the hosted service today: the provider redirects to the shared callback host, which is not a workspace host, so a per-workspace app cannot be selected there. | ## The redirect URI Every provider asks for an *authorized redirect URI* (Google), *redirect URI* (Microsoft) or *authorization callback URL* (GitHub). The value must match what KnoxCall sends **byte for byte** — a trailing slash, a different scheme or a different path is a mismatch. The card shows the exact value for your deployment in the **Authorized redirect URI** box. It is the server's answer, not something derived from the page you are looking at: | Deployment | Redirect URI | | - | - | | KnoxCall Cloud | `https://auth.knoxcall.com/callback` — one URI shared by all three providers; KnoxCall tells them apart from the sign-in state. | | Self-hosted | Whatever you set in the server environment, per provider (below). Until you set it, the card shows the Cloud value, which will not work for your domain. | ### Self-hosted: set the redirect URI in the server environment On a self-hosted deployment the shared `/callback` path is not routed, so point each provider straight at its handler on your own host and register the same value with the provider: ```bash theme={"dark"} GOOGLE_OAUTH_REDIRECT_URI=https://knox.example.com/auth/google/callback MICROSOFT_OAUTH_REDIRECT_URI=https://knox.example.com/auth/microsoft/callback GITHUB_OAUTH_REDIRECT_URI=https://knox.example.com/auth/github/callback ``` Restart the API after changing them. The card reads the live values, so it shows what you set. If a provider reports a mismatch, compare the two strings character by character — that is the entire diagnosis in almost every case. ## What KnoxCall asks the provider for KnoxCall requests the minimum needed to identify the person and read a verified email address: | Provider | Scopes | | - | - | | Google | `openid`, `email`, `profile` | | Microsoft | Microsoft Graph `User.Read` (delegated) | | GitHub | `read:user`, `user:email` | Sign-in is **pure sign-in**: the token from the provider is spent on one profile lookup and not stored. There is no offline access and no refresh token, and users are not asked to re-consent on every login. (Token injection for *your* upstream APIs is a different feature — see [OAuth 2.0 secrets](/security/oauth2-flow).) ## How accounts are matched 1. A returning user (same provider, same provider user id) signs straight in. 2. Otherwise KnoxCall looks for an existing account with the **same email address** and links the provider to it — but only if the provider reports the address as **verified**. An unverified address is refused (`error=unverified_email`), because linking on an unverified address would let someone attach their provider identity to your account. 3. Otherwise a new account is created from the verified email. Email matching is case-insensitive. A person who signed up with a password can therefore add "Continue with Google" simply by signing in with the same address. ## Configuring a provider 1. **Settings → Integrations** → pick the scope → **OAuth Sign-In Methods** → *Configure* (or *Edit*). 2. Choose the provider tab. A dot on each tab shows whether it is already configured. 3. Copy the **Authorized redirect URI** into the provider's app (see the provider guide for exactly where). 4. Paste the provider's **Client ID** and **Client secret** and **Save**. Microsoft has one more field, the allowed directory IDs — read [that guide](/integrations/setup/oauth-microsoft#restrict-sign-in-to-your-directory) before leaving it blank. 5. Open the login page in a private window and use the new button. *Disconnect* on a provider tab removes the app; people who only ever signed in with that provider will need another sign-in method (a magic link or a passkey still works for them). ## Environment variables (self-hosted) The client credentials can also come from the environment. A stored row wins; the variables are the fallback when no row exists. | Provider | Client ID | Client secret | Redirect URI | | - | - | - | - | | Google | `GOOGLE_OAUTH_CLIENT_ID` | `GOOGLE_OAUTH_CLIENT_SECRET` | `GOOGLE_OAUTH_REDIRECT_URI` | | Microsoft | `MICROSOFT_OAUTH_CLIENT_ID` | `MICROSOFT_OAUTH_CLIENT_SECRET` | `MICROSOFT_OAUTH_REDIRECT_URI` | | GitHub | `GITHUB_OAUTH_CLIENT_ID` | `GITHUB_OAUTH_CLIENT_SECRET` | `GITHUB_OAUTH_REDIRECT_URI` | `MICROSOFT_OAUTH_ALLOWED_TENANT_IDS` is the environment form of Microsoft's allowed-directory list. ## Troubleshooting | What you see | What it means | | - | - | | The login page has no button for the provider | No configured, enabled app was found for it. Check the scope you saved under, and that the row is enabled. | | `/login?error=oauth_not_configured&provider=…` | Same as above, reached by URL. | | `redirect_uri_mismatch` (Google), `AADSTS50011` (Microsoft), "The redirect\_uri MUST match…" (GitHub) | The URI registered on the provider differs from the one in the card. Compare them byte for byte. | | `/login?error=unverified_email` | The provider reported an unverified address. Verify it at the provider and try again. | | `/login?error=oauth_failed` | The code-for-token exchange failed — usually a wrong client secret (Microsoft: the *Secret ID* was pasted instead of the *Value*) or a secret that has expired. | ## Related * [Single Sign-On (SAML)](/sso/overview) — enterprise federation with domain verification * [Team collaboration](/account/team-collaboration) — inviting people and roles # OpenAI Source: https://docs.knoxcall.com/integrations/setup/openai Configure OpenAI as KnoxCall's fallback AI provider for anomaly and spec analysis — a project key with restricted permissions, and where to paste it. # OpenAI The fallback provider for anomaly analysis and OpenAPI spec analysis when Anthropic is not configured, and one of the answer engines the platform's AI-visibility probes query. **Scope: Global defaults** only. Workspace AI features use the workspace's [Anthropic](/integrations/setup/anthropic) key; this card is for the platform operator (KnoxCall on Cloud, you on a self-hosted deployment). ## 1. In the OpenAI Platform 1. [platform.openai.com](https://platform.openai.com) → select or create the **project** that should own the spend. 2. **API keys → Create new secret key → Project key**, scoped to that project. 3. **Permissions: Restricted**, granting **Model capabilities: Write** (chat completions) and **Models: Read** (the health probe lists models). 4. Copy the key (`sk-proj-…`) — shown once. ## 2. Enter it in KnoxCall **Settings → Integrations → Global defaults → OpenAI** → *Configure*: | Field | Value | | - | - | | **API key** | `sk-proj-…` | **Save credentials.** ## 3. Verify With no Anthropic key configured, run AI analysis on an alert; it falls through to OpenAI. The operator console's *Test connection* lists models with the stored key — a key without *Models: Read* fails that test while still working for completions. ## Things that bite * **A stored key wins over `OPENAI_API_KEY`** on a self-hosted host; the variable is only the fallback when no row exists. * **Project keys, not legacy user keys.** A user key carries the person's full access and dies with their account. ## Related * [Anthropic](/integrations/setup/anthropic) — the primary provider # OpenTelemetry Export (OTLP) Source: https://docs.knoxcall.com/integrations/setup/opentelemetry Ship the whole gateway's traces, logs and metrics to your OTLP collector — the endpoint and header format KnoxCall expects, then copy-paste recipes for Grafana Cloud, Honeycomb, New Relic, Datadog, SigNoz, Elastic, Axiom, Sentry and a self-run OpenTelemetry Collector. # OpenTelemetry Export (OTLP) The **OpenTelemetry export** card ships the **whole gateway's** telemetry — one `SERVER` span per request, one log record per proxied request and webhook delivery, and request count / latency metrics every 60 seconds — to a single OTLP/HTTP endpoint. What each signal contains is documented in [OpenTelemetry export](/monitoring/opentelemetry); this page is the setup. **Scope: Global defaults** only. This is the deployment operator's view of KnoxCall itself: on KnoxCall Cloud it is KnoxCall's; on a self-hosted deployment it is yours. A workspace that wants *its own* request logs and metrics in *its own* collector uses [Telemetry export](/integrations/setup/telemetry-export) instead. ## The three fields | Field | What to enter | | - | - | | **OTLP HTTP endpoint** | The collector's OTLP/HTTP **base** URL. KnoxCall appends `/v1/traces`, `/v1/logs` and `/v1/metrics` itself — a value ending in `/v1/traces` produces `/v1/traces/v1/traces`. | | **OTLP auth headers** | Comma-separated `name=value` pairs, e.g. `Authorization=Bearer , X-Scope-OrgID=acme`. Stored encrypted because it carries the credential. | | **Service name** | The resource `service.name`; default `knoxcall`. | Plus three toggles — **Export traces**, **Export integration logs**, **Export metrics** — all on by default. Turn a signal off rather than pointing it at a backend that rejects it (see Sentry below). ## 1. Configure 1. **Settings → Integrations → Global defaults → OpenTelemetry export** → *Configure*. 2. Endpoint, headers, service name from the recipe for your backend below. 3. **Save.** Export starts immediately in the API server, across all its workers — no restart. ## 2. Verify Spans and log records should appear in your backend within a minute of the next proxied request; metrics on the next 60-second tick. Nothing is logged locally on success, so the backend is the check. ## Recipes Endpoints and header names are those published by each vendor at the time of writing; confirm on the vendor's OTLP settings page, which is also where you mint the credential. Mint a **write-only ingest** credential in every case — never an admin API key. ### Grafana Cloud 1. Grafana Cloud portal → your stack → **Connections → OpenTelemetry** (or **Configure** on the OTLP tile). It shows the endpoint, your **instance id** and a **token** generation button. 2. Create a token with the `metrics:write`, `logs:write` and `traces:write` scopes. 3. Grafana authenticates with HTTP Basic: `base64(":")`. | Field | Value | | - | - | | Endpoint | `https://otlp-gateway-.grafana.net/otlp` | | Headers | `Authorization=Basic ` | ### Honeycomb 1. **Environment settings → API keys → Create ingest key** (Send events only). 2. Metrics land in a dataset named by a header; traces and logs use the service name. | Field | Value | | - | - | | Endpoint | `https://api.honeycomb.io` (EU: `https://api.eu1.honeycomb.io`) | | Headers | `x-honeycomb-team=, x-honeycomb-dataset=knoxcall-metrics` | ### New Relic Use an **ingest license key**, not a user key. | Field | Value | | - | - | | Endpoint | `https://otlp.nr-data.net` (EU: `https://otlp.eu01.nr-data.net`) | | Headers | `api-key=` | ### Datadog Datadog ingests OTLP through the **Datadog Agent** or an OpenTelemetry Collector with the Datadog exporter, not a public OTLP URL. Enable the Agent's OTLP/HTTP receiver (`otlp_config.receiver.protocols.http.endpoint: 0.0.0.0:4318`) and point KnoxCall at it — over a private network or TLS, since the Agent has no auth of its own. | Field | Value | | - | - | | Endpoint | `https://:4318` | | Headers | *(none)* — put the receiver on a private network or behind a TLS proxy | ### SigNoz | Field | SigNoz Cloud | Self-hosted SigNoz | | - | - | - | | Endpoint | `https://ingest..signoz.cloud:443` | `https://:4318` | | Headers | `signoz-ingestion-key=` | *(none by default)* | ### Elastic Elastic Cloud and Elastic APM Server accept OTLP/HTTP natively. | Field | Value | | - | - | | Endpoint | The APM Server / managed OTLP endpoint of your deployment (`https://.apm..cloud.es.io`) | | Headers | `Authorization=ApiKey ` (or `Authorization=Bearer `) | ### Axiom | Field | Value | | - | - | | Endpoint | `https://api.axiom.co` | | Headers | `Authorization=Bearer , X-Axiom-Dataset=` | ### Sentry Sentry ingests OTLP **traces and logs** into the same project as your errors, but **not metrics** — untick **Export metrics** or the metric exporter is rejected every 60 seconds. Values and the caveats are in [Sentry as an OTLP backend](/monitoring/opentelemetry#sentry-as-an-otlp-backend). ### Splunk, Dynatrace and everything else: a self-run Collector Any backend that speaks OTLP or has a Collector exporter can be reached through an [OpenTelemetry Collector](https://opentelemetry.io/docs/collector/) you run. Point KnoxCall at the Collector's OTLP/HTTP receiver and fan out from there: ```yaml theme={"dark"} receivers: otlp: protocols: http: endpoint: 0.0.0.0:4318 exporters: otlphttp/backend: endpoint: https://your-backend.example/otlp headers: Authorization: "Bearer ${env:BACKEND_TOKEN}" service: pipelines: traces: { receivers: [otlp], exporters: [otlphttp/backend] } logs: { receivers: [otlp], exporters: [otlphttp/backend] } metrics: { receivers: [otlp], exporters: [otlphttp/backend] } ``` Put a TLS terminator with a bearer-token check in front of the receiver, and give KnoxCall that token in **OTLP auth headers** — a bare receiver on a public address accepts telemetry from anyone. ## Environment variables, and who reads what | Variable | Purpose | | - | - | | `OTEL_EXPORTER_OTLP_ENDPOINT` | Base endpoint — **when set, the stored row is ignored entirely** | | `OTEL_EXPORTER_OTLP_HEADERS` | Headers, `name=value,name2=value2` | | `OTEL_SERVICE_NAME` | Service name | | `OTEL_TRACES_EXPORTER=none` / `OTEL_LOGS_EXPORTER=none` / `OTEL_METRICS_EXPORTER=none` | Disable one signal | | `OTEL_EXPORTER_OTLP_{TRACES,LOGS,METRICS}_ENDPOINT` and `_HEADERS` | Send one signal elsewhere. A signal endpoint is used **verbatim** (give the full `/v1/` path) and its headers **replace** the generic ones. | Two rules a self-hosted operator needs to know: * **The environment wins.** This is the opposite of most integrations. If `OTEL_EXPORTER_OTLP_ENDPOINT` is set on the host, editing the card changes nothing. * **The stored row reaches the API server only.** Scheduled jobs, workflow workers and the standalone proxy read the environment variables and never the row. If you want every process covered, configure the environment; use the card for a quick change on the API server. ## Things that bite * **Base endpoint only.** The most common mistake is pasting a URL that already ends in `/v1/traces`. * **Headers are a single line.** `name=value` pairs separated by commas; a header value containing a comma cannot be expressed here — use the environment variable form with an env-quoted value, or a Collector. * **Metrics carry a `knoxcall.tenant_id` dimension** on this export, so cardinality grows with workspaces; per-workspace exports do not carry it. ## Related * [OpenTelemetry export](/monitoring/opentelemetry) — what each signal contains, semantic conventions * [Telemetry export (per workspace)](/integrations/setup/telemetry-export) * [Error tracking (Sentry)](/integrations/setup/sentry) # Operator Push (Handoff Alerts) Source: https://docs.knoxcall.com/integrations/setup/operator-push Get a phone push when the in-app assistant hands a conversation to a human — ntfy, Telegram or Pushover, one provider, and the fields each one needs. # Operator Push (Handoff Alerts) When the support assistant escalates a chat to a person, KnoxCall pushes a notification to the operator's phone. Pick **one** provider. **Scope: Global defaults** only — this is a platform operator feature. On a self-hosted deployment without the support assistant, leave it unconfigured. ## 1. Pick a provider ### ntfy The default. A **public topic on ntfy.sh needs no credential at all** — but anyone who guesses the topic name can read it, so use an unguessable name (`knoxcall-support-7f3a9c…`) or a self-hosted, auth-locked ntfy server. 1. Choose the topic name and subscribe to it in the ntfy app. 2. Self-hosted ntfy with auth: create an access token for a user that may publish to the topic. ### Telegram 1. Message **@BotFather** → `/newbot` → copy the bot token (`123456789:ABC…`). 2. Send your new bot one message — the chat does not exist until you do. 3. Read your chat id from `https://api.telegram.org/bot/getUpdates` (the `chat.id` field). ### Pushover 1. Pushover → **Create an Application/API Token** → copy the application token. 2. Your **user key** is on the Pushover dashboard. ## 2. Enter it in KnoxCall **Settings → Integrations → Global defaults → Operator push (handoff alerts)** → *Configure*. Fill in the rows for your provider and leave the rest blank: | Field | ntfy | Telegram | Pushover | | - | - | - | - | | **Provider** | `ntfy` (or blank) | `telegram` | `pushover` | | **ntfy server URL** | Only for a self-hosted server (default `https://ntfy.sh`) | | | | **ntfy topic** | The topic | | | | **ntfy access token** | Only for an auth-locked server | | | | **Telegram bot token** | | The BotFather token | | | **Telegram chat ID** | | The chat id | | | **Pushover app token** | | | The application token | | **Pushover user key** | | | Your user key | **Save credentials.** ## 3. Verify Trigger a hand-off in the assistant, or — for ntfy — publish to the topic yourself and confirm the phone buzzes. ## Things that bite * **Set Provider explicitly for Telegram and Pushover.** With it blank, KnoxCall only infers *ntfy* (from a topic being present). A Telegram token with no provider sends nothing, silently. * **Environment fallback.** Every field also falls back to an `OPERATOR_PUSH_*` environment variable on a self-hosted host, so an empty row does not prove push is off. * **Public ntfy topics are public.** Treat the topic name as a secret. ## Related * [Support chat](/support/support-chat) # Integration Setup Guides Source: https://docs.knoxcall.com/integrations/setup/overview Step-by-step guides for every card on the Settings → Integrations page — which scope each credential is read from, what to create at the provider, exactly what to paste into KnoxCall, and how to prove it works. # Integration Setup Guides **Settings → Integrations** is where KnoxCall is given the credentials it needs to talk to other services: the email provider that sends invitations, the Twilio account behind SMS alerts, the OAuth apps behind "Continue with Google", the collector your telemetry ships to. Every card opens a detail view, and the book icon next to its title brings you to the guide for that card on this site. Each guide follows the same shape: what the integration powers, what to create on the provider's side (with least-privilege permissions), the exact fields to fill in on the KnoxCall form, how to verify it worked, and the things that bite. ## Two scopes The page has a scope switch, visible to workspace admins on a self-hosted deployment: | Scope | What it is | Who uses it | | - | - | - | | **This tenant** | Credentials that belong to your workspace. They override the platform default for anything KnoxCall does *for your workspace*. | Every workspace admin. | | **Global defaults** | The platform-wide credentials, used when no workspace override applies. On a self-hosted deployment they are **yours** — the operator is the admin, and this is where platform-level cards live. On KnoxCall Cloud they are KnoxCall's own and are managed in KnoxCall's operator console, so the switch is not shown there. | Self-hosted operators. | Some cards exist only under **Global defaults** because they have no per-workspace meaning (the CAPTCHA on the login page, the gateway-wide OpenTelemetry export, the DigitalOcean account behind static-IP egress). Others are read at platform scope even though the card is offered under both; each guide says which. If you self-host and a card "does nothing", the first thing to check is the scope you saved it under. Where a guide below says **Settings → Integrations → Global defaults**, that is the self-hosted path; on KnoxCall Cloud the same card is in the operator console. **How credentials are stored.** Secret fields are envelope-encrypted with a key that is itself encrypted at rest, and are never sent back to the browser — once saved, the form shows masked dots and *leave blank to keep current*. Non-secret fields (a region, a from-address, an endpoint) are stored as plain metadata and shown on the list. The form refuses to store anything credential-shaped in a non-secret field. **Environment variables.** A self-hosted deployment can also supply most of these credentials as environment variables. For most integrations a **stored row wins** over the variable and the variable is only a fallback when no row exists — so deleting a row does not switch a feature off on a host that still carries the variable. Two integrations invert this: for the OpenTelemetry export and the Sentry DSNs, **the environment wins**. Each guide states the rule for its card. ## The guides ### Sign-in How the three providers share one redirect URI, what scopes are requested, and when you need your own apps. Consent screen, OAuth client ID, redirect URI. App registration, client secret, and the allowed-directory list that keeps other tenants out. An organization-owned OAuth App with one callback URL. ### Email, SMS and push Resend, SendGrid, Postmark, Mailgun, Amazon SES or any SMTP host — the transport behind every platform email. SMS alerts and the Verify service that proves a phone number before it can receive them. ntfy, Telegram or Pushover, for the assistant's hand-offs to a human. ### AI Your workspace's own key for anomaly analysis, spec analysis and the workflow copilot. The fallback provider for anomaly and spec analysis. Point the AI Gateway at your own named-entity analyzer. ### Observability Ship the whole gateway's traces, logs and metrics to Grafana Cloud, Datadog, Honeycomb, New Relic, SigNoz, Elastic, or your own collector. Only your workspace's request logs and metrics, to your own collector or SIEM. Browser, backend and console DSNs, plus the org token for the issues feed. ### Storage and cloud Request and response body archival on S3, R2 or MinIO. The API token behind static-IP egress servers. The IAM identity that assumes customer KMS roles. ### Security and anti-abuse The CAPTCHA on the login page. ### Platform operator These back services the KnoxCall operator runs around the product. On a self-hosted deployment you can leave them unconfigured unless you run the same service yourself. The support widget and assistant messages. Address autocomplete on signup and address forms. Service-account read access for the console's web-analytics page. The data-export token for the console's insights card. The admin token for the status-page Worker. The RSA key behind cross-cloud Workload Identity Federation. ## Related * [Self-hosted proxy — local integrations](/infrastructure/self-hosted-proxy#local-integrations) — the minimum a self-hosted deployment needs * [Automation platforms](/integrations/overview) — Zapier, Make and n8n connectors (a different kind of integration: they call KnoxCall, rather than KnoxCall calling them) # AI Gateway PII Analyzer (Presidio) Source: https://docs.knoxcall.com/integrations/setup/presidio Point the AI Gateway at your own Microsoft Presidio analyzer for named-entity PII detection — deploying the sidecar, the URL rules on cloud versus self-hosted, timeout and confidence settings, and how degradation is reported. # AI Gateway PII Analyzer (Presidio) The AI Gateway's built-in detectors find *structured* identifiers — card numbers, national IDs, emails, keys — with pattern and checksum tiers. **Names, street addresses and free-text medical identifiers need a named-entity recognizer**, and KnoxCall runs that tier on a [Microsoft Presidio](https://microsoft.github.io/presidio/) analyzer **you** operate, so prompt text never leaves your boundary for analysis. **Scope: This tenant.** Each workspace points at its own analyzer; there is no platform default. **Before you start** * Somewhere to run a container that the KnoxCall gateway can reach over HTTPS. * Read [PII redaction](/ai-gateway/pii-redaction#presidio-optional) for what the tier adds and how it combines with policies. ## 1. Deploy the analyzer Run the official analyzer image (`mcr.microsoft.com/presidio-analyzer`) or the sidecar packaging in the KnoxCall repository (`docker/presidio-sidecar/`). KnoxCall calls `POST /analyze` on the base URL you configure, with the text, the language and the entity list. Where it must live depends on how you run KnoxCall: | Deployment | Analyzer URL | | - | - | | **KnoxCall Cloud** | A **publicly routable** host, over HTTPS, in front of your own authentication if you add any. A private address (`10.x`, `192.168.x`, `127.0.0.1`, `169.254.x`) is refused when you save it — the private network reachable from the gateway is KnoxCall's, not yours. | | **Self-hosted** | May be a private-network address on the network you own, if you tick the private-host acknowledgement below. Link-local and cloud-metadata addresses (`169.254.0.0/16`, `fe80::/10`) are refused everywhere. | Put the analyzer behind TLS and restrict who can reach it: the requests carry the prompt text being inspected. ## 2. Enter it in KnoxCall **Settings → Integrations → This tenant → AI Gateway PII analyzer (Presidio)** → *Configure*: | Field | Value | | - | - | | **Presidio analyzer URL** | Base URL, e.g. `https://presidio.your-company.com` — no `/analyze` suffix | | **Language** | `en` unless your model supports another | | **Per-call timeout (ms)** | Default `1500`. Keep it short — the built-in tiers still run if the analyzer is slow. | | **Confidence threshold (0–1)** | Default `0.4`. Matches below it are dropped. | | **Analyzer is on this deployment's private network** | Self-hosted only. Tick it for a private address you own. Ignored on Cloud. | **Save.** The URL is validated when you save: a refused destination returns `presidio_url_blocked` with the reason, rather than being stored and discovered later. ## 3. Verify Send a prompt containing a name and an address through a gateway with a PII policy that includes Presidio entities; the [PII events](/ai-gateway/pii-redaction#pii-events) ledger shows the match with the Presidio recognizer as its source. ## How failure is reported The analyzer is called on the buffered request path with a circuit breaker; five consecutive failures open it and the stack falls back to the built-in tiers. * **With a PII policy attached**, that fallback is refused rather than served: the request gets `503 pii_policy_degraded`, because a policy that promised name detection cannot be silently enforced without it. * **With no policy attached**, the request is served with `X-Knox-AI-Pii-Degraded: presidio_url_refused` or `presidio_circuit_open`, and a `detector_unavailable` row is written to the PII events ledger. ## Things that bite * **The URL is re-resolved and pinned on every call.** A hostname that later starts resolving to a private address on Cloud stops being used; check the gateway's PII events if detection quietly drops off. * **Timeout is per call, on the request path.** A generous timeout makes every gateway request that long when the analyzer is unhealthy. * **Changing the URL or threshold takes effect on the next request** — no restart. ## Related * [PII redaction](/ai-gateway/pii-redaction) — tiers, policies and the events ledger * [AI Gateway firewall](/ai-gateway/firewall) # S3 Body Archival Source: https://docs.knoxcall.com/integrations/setup/s3 Archive proxied request and response bodies to Amazon S3, Cloudflare R2 or MinIO — the bucket, a least-privilege IAM user (no ListBucket), the region, bucket and endpoint fields, and the deployment flag that switches archival on. # S3 Body Archival Large or file-typed request and response bodies are offloaded to object storage instead of Postgres, for compliance and audit retention and to keep the dashboard responsive. Any S3-compatible store works: Amazon S3, Cloudflare R2, MinIO, DigitalOcean Spaces. **Scope: Global defaults.** Archival runs on the platform credential. On KnoxCall Cloud it is KnoxCall's bucket and there is nothing to configure; on a self-hosted deployment save your bucket under **Global defaults**. The card is also offered under *This tenant*, but a workspace-scoped S3 row is not used for archival today — save it at platform scope. ## 1. In AWS (or your S3-compatible provider) 1. **Create the bucket first**, with **Block Public Access** on and default encryption enabled. Note its region. 2. **IAM → Users → Create user**, *without* console access. 3. Attach an inline policy granting **object operations only** on that one bucket: ```json theme={"dark"} { "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"], "Resource": "arn:aws:s3:::/*" }] } ``` `s3:ListBucket` is **not** needed — KnoxCall addresses objects by key and never enumerates the bucket. No other service, no other bucket. 4. **Security credentials → Create access key → Application running outside AWS**. Copy both halves. **Cloudflare R2:** R2 → *Manage R2 API tokens* → a token with *Object Read & Write* on the one bucket; the endpoint is `https://.r2.cloudflarestorage.com` and the region is `auto`. **MinIO:** a service account scoped to the bucket; the endpoint is your MinIO URL. ## 2. Enter it in KnoxCall **Settings → Integrations → Global defaults → S3** → *Configure*: | Field | Value | | - | - | | **Access key ID** | `AKIA…` (or the R2/MinIO key id) | | **Secret access key** | The secret half | | **Region** | The bucket's region, e.g. `eu-west-1`; `auto` for R2 | | **Bucket** | The bucket name | | **Endpoint (R2/MinIO)** | Only for a non-AWS store, e.g. `https://.r2.cloudflarestorage.com` | **Save credentials.** ## 3. Switch archival on (self-hosted) The credential is *where* bodies go; a deployment flag decides *whether* they go. On a self-hosted host set in the server environment: ```bash theme={"dark"} S3_RESPONSE_STORAGE_ENABLED=true # optional: S3_RESPONSE_MAX_FILE_SIZE (bytes, default 10 MB), S3_LARGE_BODY_THRESHOLD (bytes, default 10 KB) ``` Bodies larger than the threshold, and file-typed bodies, are archived; the log entry keeps a reference and the dashboard fetches on demand. ## 4. Verify Proxy a request with a large JSON response and open it under **API logs** — the body loads from the archive. On the store side, objects appear under an environment-named prefix. ## Things that bite * **Both key halves, or nothing.** With one half stored the row is ignored and a self-hosted host falls back to the `AWS_S3_*` environment variables, or to no archival. * **Region mismatch** produces a `301` on every write. The region must be the bucket's. * **Storing the key does not turn archival on.** Without the flag the row is inert. * **Deletion and retention** are KnoxCall's: the retention purge deletes archived objects when it deletes the log rows they belong to, which is why `DeleteObject` is in the policy. ## Related * [API logs](/monitoring/api-logs) * [Audit logs](/monitoring/audit-logs) # Error Tracking (Sentry) Source: https://docs.knoxcall.com/integrations/setup/sentry Send KnoxCall's admin-UI, backend and operator-console exceptions to Sentry — the projects to create, which DSN goes in which field, the read-only organization token for the console's issues feed, and the environment-precedence rule. # Error Tracking (Sentry) Two independent things on one card: * **Error capture** — the browser SDK in the admin UI, the Node SDK in the backend, and a separate browser SDK in the operator console, each with its own **DSN**. * **The operator console's issues feed** — recent unresolved issues, read with an **organization auth token**. Either half works without the other. What is captured, what is deliberately not (no session replay, no PII, no cookies or bodies) and how source maps work is documented in [Error tracking](/monitoring/opentelemetry#error-tracking-sentry); this page is the setup. **Scope: Global defaults** only — errors are platform-wide. ## 1. In Sentry ### Projects and DSNs 1. Create one project per SDK: a **React** project for the admin UI, a **Node.js** project for the backend, and — if you run it — a **React** project for the operator console. One project for everything works, but separate projects give cleaner releases, source maps and access control. The console gets its own on purpose: its events identify operators, not tenants, so it can be switched on independently. 2. For each project: **Settings → Client Keys (DSN)** → copy the DSN (`https://@o.ingest..sentry.io/`). A DSN identifies a project; it is not a secret and is delivered to browsers by design. ### Organization token (issues feed, optional) 1. **Organization Settings → Auth Tokens → Create New Token** with exactly `org:read`, `project:read` and `event:read`. Read-only — the console never resolves or mutes issues. Use an **organization** token, not a personal user token that dies with the account. 2. Copy it (`sntrys_…`) — shown once. 3. Note your **organization slug**: the segment after `sentry.io/organizations/` in any Sentry URL. ## 2. Enter it in KnoxCall **Settings → Integrations → Global defaults → Error tracking (Sentry)** → *Configure*: | Field | Value | | - | - | | **Sentry DSN — browser (admin UI errors)** | The admin-UI project's DSN | | **Sentry DSN — server (backend errors)** | The backend project's DSN | | **Sentry DSN — operator console** | The console project's DSN (leave blank if you do not run the console) | | **Organization slug** *(optional)* | e.g. `my-org` | | **Organization auth token** *(optional)* | `sntrys_…` | Any subset is a valid save: DSNs only, token only, or all of it. **Save credentials.** ## 3. Verify * **Backend DSN**: applies immediately. The next `5xx` — or any unhandled exception — appears in the backend project. * **Browser DSNs**: delivered to the SPA at page load, so reload the admin UI; a React render error or an unhandled rejection appears in the browser project. * **Issues feed**: the operator console's *Test connection* reads the organization with the stored token. A `403`/`404` there almost always means the slug is wrong or the token lacks `org:read`. ## Things that bite * **The environment wins for the DSNs.** `SENTRY_DSN`, `SENTRY_DSN_BACKEND` and `SENTRY_DSN_CONSOLE` override the stored values on a self-hosted host. The organization token has no environment form. * **Scheduled jobs, workflow workers and the standalone proxy read the backend DSN from the environment only** — deliberately, because the database being down is exactly when an error report matters. A DSN saved only on this card reaches the API process; set `SENTRY_DSN_BACKEND` to cover every process. * **Disabling the row switches both halves off** — capture and the issues feed. * **Warn-level logs are off by default.** Set `SENTRY_LOGS=1` to ship warn and error log lines alongside exceptions; log ingest is billed by volume, so it is never enabled as a side effect of setting a DSN. * **Minified browser stacks.** Source maps are uploaded at build time with `SENTRY_ORG`, `SENTRY_PROJECT` and `SENTRY_AUTH_TOKEN` on the build machine — not runtime configuration. * **Sub-processor.** On the hosted service a DSN makes Sentry a sub-processor; self-hosted, the data goes to your project. Review your disclosures before enabling it in production. ## Related * [Error tracking](/monitoring/opentelemetry#error-tracking-sentry) — capture semantics, privacy, source maps * [Sentry as an OTLP backend](/monitoring/opentelemetry#sentry-as-an-otlp-backend) # Status Page Source: https://docs.knoxcall.com/integrations/setup/status-page Let the operator console post incidents to the status-page Worker — generate a shared admin token, set it on the Worker, and enter the same value here with the Worker URL. # Status Page The public status page is a Cloudflare Worker. This card holds the **shared admin token** the console presents when it posts incidents and updates to it. It is not a vendor credential — it is a value you generate and configure on both sides. **Scope: Global defaults** only — a platform operator feature. ## 1. Generate and deploy the token 1. Generate a high-entropy token: ```bash theme={"dark"} openssl rand -base64 32 ``` 2. Set it on the Worker as its admin-token secret (`wrangler secret put …` for the Worker's admin-token binding) and deploy the Worker. ## 2. Enter it in KnoxCall **Settings → Integrations → Global defaults → Status page** → *Configure*: | Field | Value | | - | - | | **Worker admin token** | The **same** value you set on the Worker | | **Worker URL** *(optional)* | Only if the Worker is not at the default status host | **Save credentials.** ## 3. Verify Open the console's status admin page. A failed load, or a `401` from the Worker, means the two values have diverged. ## Things that bite * **Rotation is two-sided.** Change it on the Worker and here, or incident posting breaks. Save the new value here first if the Worker still accepts the old one during a rollout. * **Environment fallback**: `STATUS_ADMIN_TOKEN` is used when no row is stored. * **The token is full control of the status page.** There is no scoping. # Telemetry Export (OTLP, per workspace) Source: https://docs.knoxcall.com/integrations/setup/telemetry-export Stream your workspace's request logs and analytics metrics — and nothing else — to your own OTLP collector or SIEM: the endpoint and headers to enter, what ships, and how it differs from the operator export. # Telemetry Export (OTLP, per workspace) Every request KnoxCall proxies for your workspace, and the request-count and latency metrics derived from them, can be streamed to a collector **you** run — Datadog, Grafana, Splunk, SigNoz, Elastic, or an OpenTelemetry Collector in front of anything. Only your workspace's records ship to it; there is no operator data in the stream and no other workspace's. **Scope: This tenant.** There is no platform default; each workspace configures its own destination. ## What ships | Signal | Content | | - | - | | **Logs** | One `knoxcall.proxy.request` record per proxied request and one `knoxcall.webhook.delivery` record per webhook delivery: method, path, status, upstream host, request id, route name, latency. No bodies and no headers — those stay in [API logs](/monitoring/api-logs). | | **Metrics** | `knoxcall.proxy.requests` (counter) and `knoxcall.proxy.request.duration` (histogram, ms), by status code, status class, route and environment, exported every 60 seconds. | No traces. Traces belong to the operator export, because a trace spans the whole platform. ## 1. Configure 1. Mint a write-only ingest credential at your backend and find its OTLP/HTTP base endpoint. The values are identical to the operator export's — use the [recipes](/integrations/setup/opentelemetry#recipes) for Grafana Cloud, Honeycomb, New Relic, Datadog, SigNoz, Elastic, Axiom and a self-run Collector. 2. **Settings → Integrations → This tenant → Telemetry export (OTLP)** → *Configure*: | Field | Value | | - | - | | **OTLP HTTP endpoint** | The **base** URL, e.g. `https://otlp.your-collector.com`. KnoxCall appends `/v1/logs` and `/v1/metrics`. | | **OTLP auth headers** | `name=value, name2=value2`, e.g. `Authorization=Bearer `. Stored encrypted. | | **Service name** | Resource `service.name`; default `knoxcall`. | | **Export request logs** / **Export analytics metrics** | Toggles, both on by default. | 3. **Save.** Applies on the next request — no restart. ## 2. Verify Make a request through one of your routes. The log record arrives within the batch interval (a few seconds); the first metric point on the next 60-second tick. ## Things that bite * **Base endpoint only** — never a URL that already ends in `/v1/logs`. No `?query`, no `#fragment` and no `user:password@` in it: KnoxCall appends the signal path, and credentials belong in **OTLP auth headers**, which are stored encrypted. * **Public destinations only.** The endpoint must resolve only to public addresses. Loopback, private ranges (10/8, 172.16/12, 192.168/16, 100.64/10), link-local and the cloud metadata address, and KnoxCall's own hosts are refused when you save, and again every time KnoxCall opens a connection, so a name that later changes to a private address stops receiving exports. A workspace export cannot reach a collector on a private network, including on a self-hosted install. The operator export can. * **Redirects are not followed.** A collector that answers with a redirect gets nothing more. * **Headers KnoxCall will not send:** `Host`, `Content-Length`, `Cookie`, hop-by-hop headers, the forwarding and client-IP family (`X-Forwarded-*`, `Forwarded`, `X-Real-IP`, `True-Client-IP`, `CF-*`), and any `X-KnoxCall-*` or `X-Knox-*` header. These are refused on save. * **Export must never affect your traffic**, so a failing collector is not reported on the request path: a wrong token shows up as *nothing arriving*, not as an error in KnoxCall. Check the backend, then the headers. * **Changes propagate within about a minute** across all gateway workers; the worker that took your save switches immediately. * **Disabling the row stops the stream**; deleting it does too. There is no environment fallback for a workspace export. ## Related * [OpenTelemetry export](/monitoring/opentelemetry) — signal contents and conventions * [OpenTelemetry export (operator)](/integrations/setup/opentelemetry) — the whole-gateway export, and the backend recipes * [Analytics](/monitoring/analytics) — the same metrics in the dashboard # Cloudflare Turnstile Source: https://docs.knoxcall.com/integrations/setup/turnstile Put Cloudflare Turnstile on the KnoxCall login page to stop bot-driven email enumeration — the widget to create, the hostnames to restrict it to, and the site key and secret key fields. # Cloudflare Turnstile Turnstile is the CAPTCHA on the login page. Its job is to stop bots enumerating email addresses through the magic-link and passkey endpoints, which — unlike a password form — must answer "check your inbox" whether or not an address exists. **Scope: Global defaults** only: the login page renders before any workspace is known. On KnoxCall Cloud it is already on; a self-hosted deployment creates its own widget. ## 1. In Cloudflare 1. Cloudflare dashboard → **Turnstile → Add widget**. 2. **Hostnames**: add every host that serves the login page — production, staging, and any custom domain. A widget with no hostname restriction can be embedded on anyone's site. 3. **Widget mode: Managed** (it decides when to show an interactive challenge). 4. **Create**, then copy the **Site key** and the **Secret key** (both begin `0x4AAAAAAA…`). No API token and no account permissions are involved — Turnstile issues a key pair per widget. ## 2. Enter it in KnoxCall **Settings → Integrations → Global defaults → Cloudflare Turnstile** → *Configure*: | Field | Value | | - | - | | **Secret key** | Server-side verification key | | **Site key** | Rendered into the login page | Both are required. **Save credentials.** ## 3. Verify Open the login page in a private window: the widget renders only when the site key is stored. Request a magic link — a wrong *secret* key shows up here as a verification failure at sign-in, not at render. ## Things that bite * **Both keys, or nothing useful.** With only the secret stored the widget never renders; with only the site key stored, verification is skipped. * **Hostnames must include every login host**, or the widget errors on the ones you forgot. * **Verification is defence in depth.** If the credential cannot be read at sign-in time, the login path continues rather than locking everyone out — a broken configuration degrades silently by design. Check the widget renders after any change. ## Related * [Threat model](/security/threat-model) # Twilio Source: https://docs.knoxcall.com/integrations/setup/twilio Connect Twilio to KnoxCall for SMS alerts and phone verification — a dedicated subaccount, the Account SID and Auth Token, a sending number, and the Verify service without which no new SMS alert destination can be added. # Twilio Twilio sends KnoxCall's **SMS alert notifications** and runs the **phone verification** that proves a number belongs to someone before it can receive them. **Scope: Global defaults.** SMS is sent on the platform credential. On KnoxCall Cloud that is KnoxCall's Twilio account and there is nothing for you to configure; on a self-hosted deployment save your account under **Global defaults**. The card is also offered under *This tenant*, but a workspace-scoped Twilio row is not used for alert SMS today — save it at platform scope. **Before you start** * A Twilio account with billing enabled. * Admin rights on it: the credential below is a full-account credential. ## 1. In Twilio Twilio has no scoped API keys for messaging: the **Account SID + Auth Token** pair is the whole account. Contain the blast radius with a subaccount. 1. **Twilio Console → Account → Subaccounts → Create subaccount** named for KnoxCall. (Skip only if you accept that a leaked token controls your whole Twilio account.) 2. Switch into the subaccount. On its dashboard copy the **Account SID** (`AC…`) and reveal and copy the **Auth Token**. 3. **Phone Numbers → Buy a number** (or move one into the subaccount) with **SMS** capability. Note it in E.164 form, e.g. `+14155552671`. Some countries require a registered sender ID or a 10DLC/toll-free registration before SMS is delivered — Twilio's number page says so. 4. **Verify → Services → Create new**. Friendly name `KnoxCall`; leave **Fraud Guard** on — it is the anti-SMS-pumping control this exists for. Copy the **Service SID** (`VA…`). ## 2. Enter it in KnoxCall **Settings → Integrations → Global defaults → Twilio** → *Configure*: | Field | Value | | - | - | | **Account SID** | `AC…` — the subaccount's SID | | **Auth token** | The subaccount's Auth Token | | **From number** | The SMS-capable number, E.164 | | **Verify Service SID** | `VA…` | Fill in all four and **Save credentials**. ## 3. Verify Add an SMS destination to an alert (**Alerts → Phone numbers**). KnoxCall sends a Verify code to the number; entering it stores the destination. That single round trip exercises the credential, the Verify service and the sending number. The operator console additionally has a *Test connection* button that reads the account with HTTP Basic auth. ## Things that bite * **Both halves, or nothing.** With only the token (or only the SID) stored, the row is ignored and a self-hosted deployment falls back to the `TWILIO_ACCOUNT_SID` / `TWILIO_AUTH_TOKEN` environment variables — or to nothing. * **No From number: credential fine, every send fails** at Twilio, not at save time. * **No Verify service: no new SMS destinations.** Every number must be proven with a Verify code before it can receive alerts. Until a Verify service is configured the verification endpoint answers `503`, and unverified numbers are refused at the send boundary too, so alert SMS is unavailable — not degraded — without it. * **Rotating the Auth Token invalidates the old one immediately.** Save the new value in KnoxCall in the same minute. * **The Verify Service SID is an identifier, not a secret.** It sits next to the from-number in plain metadata and is useless without the SID and token. ## Related * [Alerts](/monitoring/alerts) — SMS as an alert destination * [Email](/integrations/setup/email) — the other alert transport # Zapier Source: https://docs.knoxcall.com/integrations/zapier Use KnoxCall in Zapier Zaps The KnoxCall Zapier app connects KnoxCall to 5,000+ apps with comprehensive coverage of every KnoxCall resource. ## Connect your account 1. Add a KnoxCall step to a Zap and choose **Connect a new account**. 2. Pick your **Environment** (Production or Sandbox). 3. Paste the **Client ID** and **Client Secret** from KnoxCall → **Security → API credentials**. 4. Zapier verifies the connection and labels it with your tenant slug. ## Triggers **Instant** (delivered the moment an event happens): * New Completed / Successful / Failed Request, plus 4xx, 5xx, received, redirect, and timeout variants — optionally filtered to a single route. * **New Audit Event (SIEM)** — every config change, auth event, and rotation. **Polling** (checked on a schedule): New Audit Log, New Route, New Secret, New Database Credential Lease, New Webhook Delivery. ## Actions The headline, security-forward actions: * **Proxy a Request (inject secrets)** — call any API through KnoxCall with `{{token: TOKEN_ID}}` placeholders resolved server-side. * **Tokenize / Bulk Tokenize / Detokenize** — keep PII out of your Zaps. * **Encrypt / Decrypt / Sign / Sign JWT** — managed cryptography. * **Issue / Revoke Database Credential**, **Issue / Revoke Certificate**, **Rotate OAuth Client Secret**, **Create / Set Secret**. Plus full CRUD across routes, secrets, vaults, crypto keys, PKI, dynamic DB credentials, webhooks, clients, environments, OAuth clients, API keys, and agents. ## Searches Find Route, Find Secret, Get OAuth2 Secret Token, Get Crypto Public Key, List Audit Logs, Get Account / Usage, and more — handy for lookups inside a Zap. ## Example: tokenize a card number before storing it 1. **Trigger:** New row in your form/CRM app. 2. **KnoxCall → Tokenize a Value** — vault `cards`, value = the card number. 3. **Store** the returned token (not the raw number) in your database. 4. Later, **KnoxCall → Proxy a Request** to your payment processor with `{{token: TOKEN_ID}}` in the body — the real number is injected only at the edge. # Introduction Source: https://docs.knoxcall.com/introduction The API gateway that keeps your credentials secure and your traffic visible. # Introduction KnoxCall sits between your clients and your backend APIs — injecting secrets, enforcing access rules, logging every request, and triggering automations. Set up a route once; everything flows through KnoxCall from there. ## Get started Create your first proxied route in five minutes Understand Routes, Clients, and Secrets Let AI configure your first route automatically Real-world examples for Stripe, Twilio, and more ## Explore Forward requests to any backend API with full control over headers, secrets, and transformations Store credentials server-side and inject them at request time — clients never see them Control which IPs, API keys, or mTLS certificates can reach each route Get notified on request success, failure, or custom conditions in real time Chain API calls with conditional logic, loops, and error handling Full request logs, analytics dashboards, and configurable alerting **For AI assistants:** Read the [AI Integration Guide](/advanced/ai-integration-guide) before helping a developer integrate their API with KnoxCall. It contains critical details about how proxy routing works. # Alerts & Notifications Source: https://docs.knoxcall.com/monitoring/alerts Configure intelligent alerts for route failures, high latency, unauthorized access, and rate limits. Get notified via email, SMS, or Slack. # Alerts & Notifications Get notified immediately when your routes experience failures, high latency, unauthorized access attempts, or rate limit issues. ## What Are Alerts? **Alerts** monitor your routes in real-time and send notifications when specific conditions are met. **Example:** ```text theme={"dark"} Route: stripe-payments Condition: 5+ failed requests (5xx errors) within 5 minutes Action: Send email to ops@example.com + Slack #incidents channel ``` **Use cases:** * 🚨 Detect API integration failures instantly * ⏱️ Monitor response time degradation * 🔒 Track unauthorized access attempts * 📊 Catch rate limit violations * 🛠️ Proactive issue resolution before customers complain ## Alert Types KnoxCall supports four types of alerts: ### 1. Request Failures **What it monitors:** Failed requests (5xx status codes) **Common scenarios:** * Backend API is down (502, 503, 504) * Internal server errors (500) * Backend timeout issues **Example condition:** ```json theme={"dark"} { "threshold": 5, "window_minutes": 5, "include_status_codes": [500, 502, 503, 504] } ``` **Triggers when:** 5 or more 5xx errors occur within 5 minutes **Use case:** ```text theme={"dark"} Your Stripe integration suddenly starts returning 503 errors. Alert triggers after 5 failures → Email sent → Team investigates immediately → Discover Stripe API outage → Switch to backup payment processor ``` ### 2. High Latency **What it monitors:** Request response times (P95 percentile) **Common scenarios:** * Backend database slow queries * Network issues * Service degradation **Example condition:** ```json theme={"dark"} { "threshold_ms": 2000, "percentile": 95, "window_minutes": 5, "min_requests": 10 } ``` **Triggers when:** P95 latency exceeds 2 seconds (2000ms) over 5 minutes, with at least 10 requests **Use case:** ```text theme={"dark"} User complaints about slow checkout page. Alert triggers when P95 latency hits 2.5s → Team investigates → Find database index missing → Add index → Latency drops to 300ms ``` **Percentiles explained:** * **P50 (median)**: 50% of requests faster than this * **P95**: 95% of requests faster than this (catches slowest 5%) * **P99**: 99% of requests faster than this (extreme outliers) ### 3. Rate Limit Exceeded **What it monitors:** Requests hitting your configured rate limits **Common scenarios:** * Client sending too many requests * Runaway script or bot * DDoS attempt **Example condition:** ```json theme={"dark"} { "threshold_percent": 80, "window_minutes": 10 } ``` **Triggers when:** Rate limit utilisation exceeds 80% within 10 minutes **Use case:** ```text theme={"dark"} Client accidentally deploys infinite loop calling your API. Alert triggers → Email sent → You contact client → They fix the bug → Prevents unnecessary API costs ``` ### 4. Unauthorized Client **What it monitors:** Requests from non-whitelisted IP addresses **Common scenarios:** * Security breach attempt * Client using wrong IP after server migration * Misconfigured firewall **Example condition:** ```json theme={"dark"} { "immediate": true, "threshold": 1, "window_minutes": 5 } ``` **Triggers when:** Unauthorized IP tries to access route (immediate alert) **Use case:** ```text theme={"dark"} Random IP address attempts to access your internal API. Alert triggers immediately → Security team reviews → Block malicious IP → Prevent potential breach ``` ### 5. Vault Destination Monitoring (automatic) Not a template you pick. The moment your workspace has its first vault, KnoxCall installs a **Vault Destination Monitoring** alert for you — no Routes required, no configuration, and it appears in your alerts list like any other. Delete it and it stays deleted. Every time a vault releases a value, KnoxCall counts it against the destination it went to. Two rules fire on that counter: | Rule | Fires when | | - | - | | **New destination** | A vault releases a value to a destination it has not used in the last 30 days. | | **Volume spike** | A window's count for one vault-and-destination pair is far above the baseline learned for that pair. | The alert names the vault, the destination and the count, and reaches you through the same channels as every other alert. It never contains the token or the value. **Live traffic only.** Test-mode detokenizes are counted in a separate history — which is what stops a test run teaching the detector that an unfamiliar host is familiar — but they never raise an alert, for the same reason no other alert fires on Test traffic: alerting is a production concern. A destination is bucketed by the pattern it matched on the vault's `allowed_destinations` list, so `api.stripe.com` and `files.stripe.com` under `*.stripe.com` are one destination — a second endpoint at a processor you already approved is not an alert; a processor you never approved is. ## Notification Channels Alerts can be sent through multiple channels: ### Email Notifications **Configuration:** ```text theme={"dark"} Email Addresses: ops@example.com, john@example.com, team@example.com ``` **Format:** ```text theme={"dark"} Subject: [CRITICAL] stripe-payments: Integration Failure Body: Alert: Integration Failure Route: stripe-payments Severity: Critical Time: 2025-01-15 10:30:45 UTC Condition Met: 5+ failures within 5 minutes Recent Failures: - 10:30:42 UTC - 503 Service Unavailable - 10:30:40 UTC - 502 Bad Gateway - 10:30:38 UTC - 502 Bad Gateway - 10:30:35 UTC - 503 Service Unavailable - 10:30:33 UTC - 500 Internal Server Error View Route: https://knoxcall.com/routes/route_abc123 View Alert: https://knoxcall.com/alerts/alert_def456 ``` **Best for:** Primary notification channel, detailed information ### SMS Notifications SMS is an **Enterprise** channel, and every destination must be **verified** before it can receive alerts: KnoxCall texts a code to the handset and you enter it once. Verify a number under **Alerts → Phone numbers**, or inline when you add an SMS destination to an alert. Verification is enforced when the alert is **sent**, not only when it is saved. A number showing as **Unverified** on the Phone numbers page is **not receiving alerts** — any alert pointed at it is failing to deliver, and the reason is recorded on the alert log. Verify it to restore paging. This applies to alerts and to severity routing defaults alike — a number that has not been verified is refused wherever you try to add it. **Configuration:** ```text theme={"dark"} Phone Numbers: +15551234567, +15559876543 (each verified once, then reusable) ``` **Format:** ```text theme={"dark"} [KnoxCall CRITICAL] stripe-payments: 5 failures in 5min. Check email for details. ``` **Best for:** Critical alerts, on-call engineers, immediate attention ### Slack Notifications **Configuration:** ```text theme={"dark"} Webhook URL: https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXX ``` **Format:** ```text theme={"dark"} 🚨 CRITICAL ALERT: Integration Failure Route: stripe-payments Severity: Critical Time: 2025-01-15 10:30:45 UTC 5 failures detected within 5 minutes Recent errors: • 503 Service Unavailable (3) • 502 Bad Gateway (2) [View Route] [View Alert] [Acknowledge] ``` **Best for:** Team collaboration, incident response, threaded discussions ## Creating Your First Alert ### Step 1: Navigate to Alerts 1. Click **Monitoring** in sidebar 2. Select **Alerts** 3. Click **+ Create Alert** ### Step 2: Choose a Template KnoxCall provides pre-configured templates: **Integration Failure** (Recommended) * Alert on any 5xx error * Severity: High * Cooldown: 15 minutes * Great for catching backend issues immediately **Critical Failures** * Alert on 5+ failures within 5 minutes * Severity: Critical * Cooldown: 30 minutes * Best for production environments **High Latency** * Alert when P95 latency exceeds 2 seconds * Severity: Medium * Cooldown: 20 minutes * Good for performance monitoring **Unauthorized Access Attempt** * Immediate alert on unauthorized IP * Severity: High * Cooldown: 60 minutes * Essential for security Or click **Custom Alert** to configure from scratch. ### Step 3: Configure Alert **Route:** Select the route to monitor ```text theme={"dark"} stripe-payments ``` **Alert Name:** ```text theme={"dark"} Stripe Integration Failure ``` **Description** (optional): ```text theme={"dark"} Alert when Stripe API returns 5xx errors ``` **Alert Type:** ```text theme={"dark"} Request Failures ``` **Severity:** * **Low**: Informational, review later * **Medium**: Important, check within hours * **High**: Urgent, check within 30 minutes * **Critical**: Emergency, check immediately **Alert Conditions (JSON):** ```json theme={"dark"} { "threshold": 3, "window_minutes": 5, "include_status_codes": [500, 502, 503, 504] } ``` **Cooldown (minutes):** ```text theme={"dark"} 15 ``` *Time before alert can trigger again (prevents spam)* **Aggregation (minutes):** ```text theme={"dark"} 1 ``` *Window for batching multiple events into one alert* ### Step 4: Configure Notifications #### Enable Email ```text theme={"dark"} ☑ Email Notifications Email Addresses: ops@example.com, john@example.com ``` #### Enable SMS (optional, Enterprise) ```text theme={"dark"} ☐ SMS Notifications Phone Numbers: +15551234567 ← must be verified first ``` Adding an SMS destination offers only numbers you have already verified, plus a **Verify another number** step that sends a code and confirms it without leaving the form. Numbers are verified once per tenant and can then be used on any alert or routing rule. #### Enable Slack (optional) ```text theme={"dark"} ☐ Slack Notifications Webhook URL: https://hooks.slack.com/services/... ``` **At least one channel required.** You can enable multiple channels to ensure alerts are seen. ### Step 5: Custom Message Templates (Optional) Customize notification content: **Email Subject Template:** ```text theme={"dark"} [{{SEVERITY}}] {{ROUTE_NAME}}: {{ALERT_NAME}} ``` **Email Body Template:** ```text theme={"dark"} Alert triggered: {{ALERT_NAME}} Route: {{ROUTE_NAME}} Severity: {{SEVERITY}} Time: {{TIMESTAMP}} Condition: {{CONDITION_DESCRIPTION}} {{TRIGGER_DETAILS}} View: https://knoxcall.com/alerts/{{ALERT_ID}} ``` **Available variables:** * `{{ALERT_NAME}}` - Alert name * `{{ROUTE_NAME}}` - Route name * `{{SEVERITY}}` - low/medium/high/critical * `{{TIMESTAMP}}` - When alert triggered * `{{CONDITION_DESCRIPTION}}` - Human-readable condition * `{{TRIGGER_DETAILS}}` - Specific trigger info (errors, latency, etc.) * `{{ALERT_ID}}` - Alert ID * `{{ROUTE_ID}}` - Route ID ### Step 6: Create Alert Click **Create Alert** Alert is now active and monitoring your route! 🎉 ## Alert States Alerts have three states: ### 1. OK (Green) **Meaning:** Condition is not met, everything normal **Example:** ```text theme={"dark"} Route: stripe-payments Failures: 0 in last 5 minutes State: OK ✓ ``` ### 2. Triggered (Red) **Meaning:** Condition met, notification sent **Example:** ```text theme={"dark"} Route: stripe-payments Failures: 6 in last 5 minutes (threshold: 5) State: TRIGGERED ⚠️ Notification sent: 2025-01-15 10:30:45 UTC ``` ### 3. Cooldown (Yellow) **Meaning:** Recently triggered, waiting for cooldown period **Example:** ```text theme={"dark"} Route: stripe-payments Last triggered: 5 minutes ago Cooldown: 15 minutes State: COOLDOWN (10 minutes remaining) ``` **Purpose of cooldown:** * Prevents notification spam * Gives time to fix issue * Won't trigger again until cooldown expires ## Advanced Configuration ### Condition Schemas Different alert types have different configuration options: #### Request Failures Schema ```json theme={"dark"} { "threshold": 5, // Number of failures to trigger "window_minutes": 5, // Time window to count failures "include_status_codes": [500, 502, 503, 504], // Which status codes count as failures "exclude_status_codes": [] // Optional: exclude specific codes } ``` #### High Latency Schema ```json theme={"dark"} { "threshold_ms": 2000, // Latency threshold in milliseconds "percentile": 95, // Percentile to monitor (50, 95, 99) "window_minutes": 5, // Time window to calculate percentile "min_requests": 10 // Minimum requests needed (prevents false positives) } ``` #### Rate Limit Exceeded Schema ```json theme={"dark"} { "threshold_percent": 80, // Percentage of rate limit consumed (1–100) to trigger "window_minutes": 10 // Optional: time window to evaluate utilisation } ``` #### Unauthorized Client Schema ```json theme={"dark"} { "immediate": true, // Trigger on first occurrence "threshold": 1, // Usually 1 for immediate alerts "window_minutes": 5 // Window for counting attempts } ``` ### Multi-Status Code Filtering **Include only specific errors:** ```json theme={"dark"} { "threshold": 3, "window_minutes": 5, "include_status_codes": [503, 504] // Only 503 and 504 } ``` **Exclude specific errors:** ```json theme={"dark"} { "threshold": 3, "window_minutes": 5, "include_status_codes": [500, 502, 503, 504], "exclude_status_codes": [503] // Ignore 503, alert on others } ``` **Use case:** Ignore expected 503 errors during maintenance. ### Latency Percentiles **P50 (Median):** ```json theme={"dark"} { "threshold_ms": 500, "percentile": 50, "window_minutes": 5, "min_requests": 50 } ``` *Alert if typical request exceeds 500ms* **P95 (Recommended):** ```json theme={"dark"} { "threshold_ms": 2000, "percentile": 95, "window_minutes": 5, "min_requests": 10 } ``` *Alert if slowest 5% exceed 2 seconds* **P99 (Outliers):** ```json theme={"dark"} { "threshold_ms": 5000, "percentile": 99, "window_minutes": 5, "min_requests": 100 } ``` *Alert on extreme outliers (slowest 1%)* ## Alert Management ### Viewing Alert Status **Navigate to:** Monitoring → Alerts **List view shows:** * Alert name and description * Route name * Current state (OK / Triggered / Cooldown) * Severity * Trigger count (24h) * Last triggered time * Enabled/Disabled status **Filters:** * Severity: Low, Medium, High, Critical * State: OK, Triggered, Cooldown * Enabled: All, Enabled, Disabled * Alert Type: Failures, Latency, Rate Limit, Unauthorized * Activity: 0 triggers, 1-5, 6-20, 20+ ### Viewing Alert Details Click alert name to see: **Overview:** * Current state * Trigger history graph * Recent triggers list **Configuration:** * Alert type and conditions * Notification channels * Cooldown and aggregation settings **Logs:** * When alert triggered * Notification delivery status * Error details that triggered alert ### Editing Alerts 1. Navigate to alert details 2. Click **Edit Alert** 3. Modify settings: * Change threshold * Update notification emails/phones * Adjust cooldown * Change severity 4. Click **Save Changes** **Changes take effect immediately.** ### Disabling Alerts **Temporarily disable:** 1. Navigate to alert details 2. Toggle **Enabled** switch to OFF 3. Alert stops monitoring (won't trigger) **Use cases:** * Scheduled maintenance * Known issue being fixed * Testing changes without spam **Re-enable:** 1. Toggle **Enabled** switch to ON 2. Alert resumes monitoring ### Deleting Alerts 1. Navigate to alert details 2. Click **Delete Alert** 3. Confirm deletion **Deleting an alert is permanent!** All trigger history and configuration is lost. Consider disabling instead if you might need it later. ## Alert Logs View alert trigger history: **Navigate to:** Monitoring → Alert Logs **Shows:** * When alert triggered * Which route * Severity * Trigger details (error messages, latency values, etc.) * Notification channels used * Delivery status (sent, failed) **Filter by:** * Date range * Route * Severity * Alert name **Use cases:** * Audit notification history * Troubleshoot missed alerts * Analyze incident patterns ## Best Practices ### 1. Start with Templates ✅ **Use built-in templates** when creating your first alerts **Why:** * Pre-configured with sensible defaults * Battle-tested thresholds * Clear descriptions ❌ **Don't create custom alerts immediately** ### 2. Set Appropriate Thresholds **Too sensitive (spam):** ```json theme={"dark"} { "threshold": 1, "window_minutes": 1 } ``` *Triggers on single error = too noisy* **Too lenient (miss issues):** ```json theme={"dark"} { "threshold": 50, "window_minutes": 60 } ``` *50 errors in an hour might mean users already frustrated* ✅ **Just right:** ```json theme={"dark"} { "threshold": 5, "window_minutes": 5 } ``` *Catches issues quickly without false positives* ### 3. Use Severity Correctly **Critical:** * Production payment processing down * Complete API outage * Security breach attempt **High:** * Partial service degradation * Single integration failing * Elevated error rate **Medium:** * Performance degradation * Non-critical API slow * Occasional errors **Low:** * Informational * Minor issues * For tracking/trending ### 4. Configure Cooldowns **Problem:** Alert triggers every minute → 60 emails in 1 hour **Solution:** Use cooldown ```text theme={"dark"} Cooldown: 30 minutes Result: 1 alert, wait 30 min, then can alert again if still failing ``` **Recommended cooldowns:** * **Critical alerts:** 30-60 minutes * **High alerts:** 15-30 minutes * **Medium alerts:** 60 minutes * **Low alerts:** 2-4 hours ### 5. Use Multiple Notification Channels **Redundancy strategy:** ```text theme={"dark"} Critical Alert: - Email: ops@example.com - SMS: +15551234567 (on-call engineer) - Slack: #incidents channel ``` **Why:** * Email might be missed * SMS ensures immediate attention * Slack allows team collaboration ### 6. Test Your Alerts Before going live: 1. Create test alert with low threshold: ```json theme={"dark"} { "threshold": 1, "window_minutes": 1 } ``` 2. Trigger condition (e.g., send request that returns 500) 3. Verify notifications received: * Check email inbox * Check SMS received * Check Slack message 4. Adjust configuration if needed 5. Set production thresholds ### 7. Monitor Alert Logs Weekly review: * Which alerts triggered most? * Any false positives? * Any missed incidents? **Adjust thresholds based on patterns.** ### 8. Document Your Alerts In alert description, include: ```text theme={"dark"} Description: Alerts when Stripe API returns 5xx errors. Indicates Stripe service outage or network issues between KnoxCall and Stripe. Runbook: https://wiki.company.com/runbooks/stripe-outage ``` **Benefits:** * Team knows what alert means * Clear action steps * Faster incident resolution ## Common Alert Scenarios ### Scenario 1: Backend API Outage **Problem:** Stripe API completely down **Alert Configuration:** ```text theme={"dark"} Alert Type: Request Failures Condition: { "threshold": 3, "window_minutes": 5, "include_status_codes": [502, 503, 504] } Severity: Critical Cooldown: 30 minutes Channels: Email + SMS + Slack ``` **Expected behavior:** * 3 failures → Alert triggers * Notifications sent to all channels * Team investigates * Issue resolved or escalated to Stripe * 30 minutes later, if still failing, alert again ### Scenario 2: Gradual Performance Degradation **Problem:** Database queries getting slower over time **Alert Configuration:** ```text theme={"dark"} Alert Type: High Latency Condition: { "threshold_ms": 1000, "percentile": 95, "window_minutes": 10, "min_requests": 20 } Severity: Medium Cooldown: 60 minutes Channels: Email + Slack ``` **Expected behavior:** * P95 latency hits 1.5s → Alert triggers * Team reviews logs * Identifies slow query * Optimizes or adds caching * Latency returns to normal ### Scenario 3: Security Incident **Problem:** Unknown IP trying to access internal API **Alert Configuration:** ```text theme={"dark"} Alert Type: Unauthorized Client Condition: { "immediate": true, "threshold": 1, "window_minutes": 5 } Severity: High Cooldown: 60 minutes Channels: Email + SMS + Slack ``` **Expected behavior:** * Unauthorized IP makes request → Immediate alert * Security team reviews * IP blocked if malicious * Client contacted if legitimate (e.g., moved servers) ### Scenario 4: Rate Limit Abuse **Problem:** Client's script gone rogue, hitting rate limits **Alert Configuration:** ```text theme={"dark"} Alert Type: Rate Limit Exceeded Condition: { "threshold_percent": 80, "window_minutes": 10 } Severity: Medium Cooldown: 30 minutes Channels: Email ``` **Expected behavior:** * Client reaches 80% of rate limit within 10 min → Alert * Review which client * Contact client * They fix infinite loop * Rate limit stops triggering ## Troubleshooting ### Issue: "Alert not triggering" **Check:** 1. Alert is enabled (not disabled) 2. Route is active (not disabled) 3. Condition threshold is correct (not too high) 4. Check alert state (might be in cooldown) **Debug:** ```text theme={"dark"} Route: stripe-payments Alert: Stripe Integration Failure State: Cooldown (15 minutes remaining) Last triggered: 10 minutes ago ``` *Alert won't trigger again until cooldown expires* ### Issue: "Too many notifications" **Cause:** Threshold too low or cooldown too short **Fix:** 1. Increase threshold: `1 → 5` 2. Increase cooldown: `5 minutes → 30 minutes` 3. Increase window: `1 minute → 5 minutes` ### Issue: "Not receiving email notifications" **Check:** 1. Email addresses correct (no typos) 2. Check spam folder 3. Email channel enabled 4. Alert logs show "sent" status **Debug:** Navigate to **Alert Logs** → Find trigger event → Check delivery status ### Issue: "Slack notifications not working" **Check:** 1. Webhook URL correct (starts with `https://hooks.slack.com`) 2. Slack channel enabled in alert config 3. Webhook not revoked in Slack settings **Test webhook:** ```bash theme={"dark"} curl -X POST https://hooks.slack.com/services/YOUR/WEBHOOK/URL \ -H "Content-Type: application/json" \ -d '{"text": "Test from KnoxCall"}' ``` Should see message in Slack channel. ## Related Features * **API Logs**: View detailed request history that triggered alerts * **Analytics**: Visualize trends and patterns in alert triggers * **Audit Logs**: Track who created/modified alerts ## Next Steps View requests that triggered alerts Analyze alert patterns and trends Track alert configuration changes Configure routes to monitor *** * **Level**: beginner to intermediate * **Time**: 15 minutes `alerts`, `monitoring`, `notifications`, `incidents`, `email`, `sms`, `slack` # Analytics Dashboard Source: https://docs.knoxcall.com/monitoring/analytics Visualize API performance with real-time charts, latency percentiles, error rates, and traffic patterns. Auto-refresh every 5 seconds. # Analytics Dashboard Visualize your API performance with real-time charts, latency percentiles (P50, P95, P99), error rates, and traffic patterns. ## Overview The **Analytics Dashboard** provides comprehensive metrics visualization for your routes: * 📊 **Status Code Distribution** - Success vs errors over time * ⏱️ **Latency Metrics** - P50, P95, P99 response times * 📈 **API Call Volume** - Request traffic patterns * 🚨 **Alert Triggers** - Alert activity timeline * ⏰ **Hourly Distribution** - Traffic by hour of day * 🗺️ **Geographic Heatmap** - Request origins worldwide **Auto-refresh:** Dashboard updates every 5 seconds for real-time monitoring. ## Key Metrics ### Status Code Distribution **What it shows:** Success rate vs client errors (4xx) vs server errors (5xx) **Chart type:** Stacked area chart **Example:** ```text theme={"dark"} 10:00 AM: 150 success, 5 client errors, 2 server errors 10:15 AM: 200 success, 8 client errors, 0 server errors 10:30 AM: 180 success, 3 client errors, 1 server error ``` **Use cases:** * Spot error rate spikes * Identify problematic time periods * Correlate errors with deployments ### Latency Percentiles **What it shows:** Response time distribution **Percentiles explained:** * **P50 (Median)**: 50% of requests faster than this * **P95**: 95% of requests faster than this (typical SLA metric) * **P99**: 99% of requests faster than this (slowest 1%) **Example:** ```text theme={"dark"} Time: 10:30 AM P50: 120ms (typical user experience) P95: 450ms (slow but acceptable) P99: 2100ms (outliers, investigate) ``` **Use cases:** * Monitor performance degradation * Set SLA targets (e.g., P95 \< 500ms) * Identify slow queries or bottlenecks ### API Call Volume **What it shows:** Total requests over time **Chart type:** Line chart **Example:** ```text theme={"dark"} Peak: 250 requests/min at 2:00 PM Low: 20 requests/min at 4:00 AM Average: 120 requests/min ``` **Use cases:** * Capacity planning * Identify peak traffic times * Detect unusual traffic patterns ### Summary Statistics **Displayed at top:** * **Total Calls**: Total requests in time range * **Avg Latency**: Average response time * **Error Rate**: Percentage of failed requests * **Alerts Triggered**: Number of alerts fired ## Using the Dashboard ### Step 1: Navigate to Analytics 1. Click **Monitoring** in sidebar 2. Select **Analytics** ### Step 2: Select Time Range **Quick ranges:** * Last 1 hour * Last 6 hours * Last 24 hours (default) * Last 7 days * Last 30 days **Custom range:** 1. Click **Custom Range** 2. Select start date/time 3. Select end date/time 4. Click **Apply** ### Step 3: Filter by Route (Optional) **All routes (default):** Shows aggregated metrics across all routes **Single route:** 1. Click **Filter** dropdown 2. Select specific route 3. Charts update to show only that route ### Step 4: Enable Auto-Refresh **Toggle:** Switch at top-right corner **When enabled:** * Dashboard refreshes every 5 seconds * "To" date automatically updates to current time * See live traffic in real-time **When disabled:** * Static snapshot of selected time range * Useful for analyzing specific incidents ## Advanced Features ### Comparison Mode **What it does:** Compare current period vs previous period **Example:** ```text theme={"dark"} Current: Jan 15-16 (last 24 hours) Previous: Jan 14-15 (previous 24 hours) Comparison: - Total Calls: +15% increase - Avg Latency: -5ms improvement - Error Rate: +0.5% increase (investigate!) ``` **Enable:** 1. Toggle **Comparison Mode** switch 2. Charts show both periods overlaid 3. Summary shows percentage changes **Use cases:** * Week-over-week performance * Before/after deployment comparison * Traffic growth analysis ### Hourly Distribution **What it shows:** Traffic patterns by hour (0-23) **Chart type:** Bar chart **Example:** ```text theme={"dark"} 00:00-01:00: 50 requests (low traffic) 09:00-10:00: 500 requests (morning peak) 14:00-15:00: 800 requests (afternoon peak) 20:00-21:00: 300 requests (evening) ``` **Enable:** 1. Toggle **Show Hourly Distribution** 2. View traffic by hour of day **Use cases:** * Identify peak hours for capacity planning * Schedule maintenance during low traffic * Detect unusual traffic patterns ### Show Empty Periods **What it does:** Include time periods with zero requests **Default:** Empty periods hidden (cleaner charts) **When enabled:** Shows gaps in traffic **Use cases:** * Identify service outages (unexpected gaps) * Visualize maintenance windows * Understand traffic continuity ### Geographic Heatmap **What it shows:** Request origins on world map **Features:** * Color intensity = request volume * Hover to see country/region details * Click to drill down **Use cases:** * Understand user distribution * Identify geographic issues * Plan CDN/edge deployments ## Drill-Down Analysis ### Clicking Charts Click any chart to drill down: **Status Code Chart:** * Click bar → See detailed errors for that time period * View specific error messages * Jump to logs for investigation **Latency Chart:** * Click point → See slow requests * Identify slow endpoints * View request details **API Calls Chart:** * Click point → See all requests in that period * Filter by status, route, client * Export to CSV ### Viewing Raw Data From drill-down view: 1. See tabular data for selected period 2. Sort by any column 3. Click request to view full details 4. Export filtered data ## Export Options ### Export Analytics Data **CSV Export:** 1. Select time range 2. Click **Export** button 3. Choose format: CSV 4. Download file **Included data:** * Timestamp * Total calls * Success count * Error count * Average latency * P50, P95, P99 latency **Use cases:** * Import to Excel/Google Sheets * Create custom reports * Archive historical data ## Real-Time Monitoring ### Live Dashboard Use Cases **Deployment monitoring:** ```text theme={"dark"} 1. Deploy new version 2. Open Analytics with auto-refresh enabled 3. Watch error rate and latency in real-time 4. Rollback if metrics degrade ``` **Incident response:** ```text theme={"dark"} 1. Alert fires for high error rate 2. Open Analytics dashboard 3. See error spike on Status Code chart 4. Drill down to view specific errors 5. Jump to logs for details ``` **Load testing:** ```text theme={"dark"} 1. Start load test 2. Monitor API Call Volume chart 3. Watch latency percentiles 4. Identify breaking point ``` ## Understanding Latency Percentiles ### Why Percentiles Matter **Average is misleading:** ```text theme={"dark"} 10 requests: - 9 requests: 100ms each - 1 request: 5000ms (timeout) Average: 590ms (looks bad) P95: 100ms (actually good for 95% of users) ``` **Use percentiles for accurate picture.** ### Setting SLA Targets **Recommended targets:** ```text theme={"dark"} P50 < 200ms (good user experience) P95 < 500ms (acceptable for most users) P99 < 2000ms (outliers tolerated) ``` **Monitor P95 for SLAs** - represents typical user experience excluding rare outliers. ### Investigating High Latency **P99 high but P95 normal:** * Rare slow queries (database, external API) * Specific edge cases * Low priority **P95 high:** * Systemic performance issue * Affects 5% of users * Investigate immediately **P50 high:** * Major performance problem * Affects majority of users * Critical issue ## Timezone Support **User timezone:** All timestamps displayed in your configured timezone (Settings → Profile). **Default:** UTC **Change timezone:** 1. Navigate to Settings → Profile 2. Select timezone dropdown 3. Save 4. Analytics charts update automatically ## Best Practices ### 1. Monitor During Peak Hours Know your traffic patterns: ```text theme={"dark"} Peak: 2-4 PM weekdays Monitor closely: 1:45 PM - 4:15 PM ``` ### 2. Set Up Comparison Dashboards Weekly comparison: ```text theme={"dark"} Every Monday, compare last 7 days vs previous 7 days Look for: - Traffic growth/decline - Latency trends - Error rate changes ``` ### 3. Use Auto-Refresh for Live Events **During deployments, load tests, or incidents:** * Enable auto-refresh * Keep dashboard visible on second monitor * Watch for anomalies in real-time ### 4. Combine with Alerts **Workflow:** 1. Alert fires (e.g., high latency) 2. Open Analytics dashboard 3. Identify trend and scope 4. Drill down to specific requests 5. Jump to logs for investigation ### 5. Export Historical Data **Monthly:** 1. Export last 30 days analytics 2. Archive CSV files 3. Track long-term trends 4. Compare year-over-year growth ## Troubleshooting ### Issue: "No data displayed" **Causes:** * No requests in selected time range * Route filter excluding all traffic * Time range in future **Fix:** 1. Check time range (ensure in past) 2. Clear route filter 3. Expand time range (e.g., last 7 days) ### Issue: "Charts not updating with auto-refresh" **Causes:** * Auto-refresh disabled * Browser tab not active (some browsers pause) * Network connectivity issue **Fix:** 1. Verify auto-refresh toggle is ON 2. Keep tab active/visible 3. Refresh page manually ### Issue: "Latency percentiles showing zero" **Cause:** Not enough requests to calculate percentiles (\< 10 requests) **Fix:** Wait for more traffic or expand time range ## Related Features * **API Logs**: Drill down from charts to view raw requests * **Alerts**: Set up alerts based on metrics thresholds * **Real-Time Geo**: See live traffic on world map ## Next Steps Detailed request history Configure metric alerts Live geographic tracking Manage monitored routes *** * **Level**: beginner * **Time**: 10 minutes `analytics`, `metrics`, `monitoring`, `performance`, `dashboards` # API Request Logs Source: https://docs.knoxcall.com/monitoring/api-logs View detailed API request history with filtering by status code, method, route, client, latency, and more. Export logs to CSV for analysis. # API Request Logs View detailed history of every API request proxied through KnoxCall with powerful filtering and export capabilities. ## Overview **API Logs** provide complete visibility into your API traffic: * 📋 Every request logged with full details * 🔍 Filter by status, method, route, client, latency * 📊 Search request paths and errors * 💾 Export to CSV (Pro/Enterprise plans) * 🔗 Deep links from alerts and analytics * ⏰ Timezone-aware timestamps **Retention:** Based on subscription plan (7-90 days) ## Log Entry Details Each log entry captures: ### Request Information * **Timestamp**: When request received (your timezone) * **Method**: HTTP method (GET, POST, PUT, DELETE, etc.) * **Path**: Request path including query parameters * **Route**: Which KnoxCall route handled it * **Environment**: Environment used (if specified) ### Client Information * **Client**: Client name/IP that made request * **Source IP**: Originating IP address * **User Agent**: Client's user agent string * **Geographic Location**: City, country (if available) ### Response Information * **Status Code**: HTTP response code (200, 404, 500, etc.) * **Latency**: Response time in milliseconds * **Backend Response**: Status from target API * **Error**: Error message if request failed ### Security Information * **API Key**: Which key authenticated request (key ID only, not full key) * **Authorization**: Whether authorized (client IP whitelisted) * **Secrets Used**: Which secrets injected (names only, not values) ## Viewing Logs ### Step 1: Navigate to Logs 1. Click **Logs** in the sidebar 2. Select **API Logs** 3. See recent requests (most recent first) ### Step 2: Use Filters **Filter by Status Code:** ```text theme={"dark"} ☐ 2xx Success ☐ 3xx Redirect ☐ 4xx Client Error ☐ 5xx Server Error ``` **Filter by HTTP Method:** ```text theme={"dark"} ☐ GET ☐ POST ☐ PUT ☐ DELETE ☐ PATCH ``` **Filter by Route:** Select specific route from dropdown **Filter by Client:** Select specific client from dropdown **Filter by Secret:** Show requests that used specific secret **Filter by Proxy Mode:** ```text theme={"dark"} ☐ Route (standard proxied routes) ☐ Ephemeral (one-shot ephemeral proxy sessions) ``` Use `Ephemeral` to isolate one-shot ephemeral proxy requests from your standard route traffic — useful when debugging ad-hoc agent calls or testing ephemeral sessions without cluttering your route log. **Filter by Latency:** ```text theme={"dark"} Min: ___ ms Max: ___ ms ``` **Search:** Search request paths, errors, or any text ### Step 3: View Log Details **Click any log entry** to see full details: **Request tab:** ```text theme={"dark"} Method: POST Path: /v1/charges?amount=1000 Headers: Content-Type: application/json x-knoxcall-key: tk_abc*** x-knoxcall-route: stripe-payments x-knoxcall-environment: production Body: { "amount": 1000, "currency": "usd", "source": "tok_visa" } ``` **Response tab:** ```text theme={"dark"} Status: 200 OK Latency: 234ms Headers: Content-Type: application/json X-Request-ID: req_xyz789 Body: { "id": "ch_abc123", "amount": 1000, "status": "succeeded" } ``` **Metadata tab:** ```text theme={"dark"} Route: stripe-payments Environment: production Client: production-server-01 (52.123.45.67) API Key: key_abc123... (Production Key) Secrets Used: - stripe-prod-key (injected in Authorization header) Timestamp: 2025-01-15 10:30:45 EST Geographic: New York, USA → Virginia, USA ``` ## Common Use Cases ### Use Case 1: Debugging Failed Requests **Scenario:** User reports payment failed **Steps:** 1. Filter by Status: 5xx Server Error 2. Filter by Route: stripe-payments 3. Filter by Time: Last hour 4. Find user's request 5. View error details 6. Identify issue (e.g., Stripe API down) ### Use Case 2: Investigating Slow Requests **Scenario:** Users complaining about slow checkout **Steps:** 1. Filter by Route: checkout-route 2. Filter by Latency: Min 2000ms (>2 seconds) 3. Sort by latency (highest first) 4. View slowest requests 5. Check common patterns (specific endpoint? time of day?) 6. Optimize accordingly ### Use Case 3: Security Audit **Scenario:** Suspicious activity detected **Steps:** 1. Filter by Status: 401 Unauthorized OR 403 Forbidden 2. Review source IPs 3. Identify unauthorized access attempts 4. Block malicious IPs 5. Export logs for security review ### Use Case 4: Client Verification **Scenario:** Client reports requests not reaching backend **Steps:** 1. Filter by Client: client-name 2. Filter by Time: Expected time window 3. Check if requests appear 4. If yes: Check status codes, errors 5. If no: Client not sending to correct URL/route ### Use Case 5: Secret Usage Audit **Scenario:** Which routes use a specific secret? **Steps:** 1. Navigate to Secret detail page 2. Click "View Logs" or "Usage" 3. See all requests that injected this secret 4. Verify expected routes only ## Advanced Filtering ### Combining Filters **Example: High-latency errors** ```text theme={"dark"} Status: 5xx Server Error Latency: Min 1000ms Route: stripe-payments Time: Last 24 hours ``` **Result:** Slow requests that also errored (likely timeouts) ### Search Operators **Search by path:** ```text theme={"dark"} /api/users ``` **Search by error:** ```text theme={"dark"} timeout connection refused ``` **Search by query parameter:** ```text theme={"dark"} user_id=12345 ``` ## Exporting Logs ### CSV Export (Pro/Enterprise Only) **What's exported:** * Timestamp * Method * Path * Status Code * Client name * Source IP * Latency (ms) * Error message (if any) **Steps:** 1. Apply filters to select logs 2. Click **Export** button 3. CSV downloads automatically **File name format:** ```text theme={"dark"} api-logs-{tenant-slug}-{timestamp}.csv ``` **Example:** ```text theme={"dark"} api-logs-DunderMifflin-1705326645000.csv ``` ### Use Cases for Export **Compliance audits:** * Export last 90 days * Provide to auditors * Demonstrate API usage **Custom analysis:** * Import to Excel/Google Sheets * Create pivot tables * Generate custom reports **Archiving:** * Export monthly logs * Store in secure location * Maintain historical record beyond retention period ## Pagination **Logs per page:** 10 (configurable) **Navigation:** * **Next/Previous** buttons * **Jump to page** dropdown * **Total count** displayed **Performance:** * Indexed queries (fast even with millions of logs) * Lazy loading (only fetch current page) ## Retention Policies **By subscription plan:** | Plan | Retention Period | | - | - | | Starter | 7 days | | Pro | 30 days | | Enterprise | 90 days (customizable) | **After retention:** * Logs automatically deleted * Export before expiration if needed * Upgrade plan for longer retention ## Deep Links **Jump to logs from:** **Alerts:** * Alert triggers → View triggering requests **Analytics:** * Click chart → Drill down to specific time period **Route Details:** * View logs for specific route **Client Details:** * View logs from specific client **Secret Details:** * View requests using secret ## Timezone Handling **Timestamps displayed in:** * Your configured timezone (Settings → Profile) * Default: UTC **Format:** ```text theme={"dark"} 2025-01-15 10:30:45 EST ``` **Includes:** * Date * Time (24-hour format) * Timezone abbreviation ## Who can see captured payloads A request log's detail is **metadata** for everyone: status, timing, path, egress, client and injection summary. Its captured headers and bodies, and the archived request and response bodies, are withheld until you click **Reveal payload** (the API equivalent is `GET /admin/logs/{request_id}?include=payload`). Downloading or previewing an archived body is a reveal of its own: the **Download** and **Preview** buttons appear on the page as soon as it opens, so you do not have to reveal the inline headers and bodies first. In the dashboard this is a **Class B reveal**: only an **Owner** or an **Admin** can do it (an Editor cannot, even with **Reveal secrets** turned on; that setting covers secret values, certificates and credentialed workflow tests only), and every use asks for a fresh second factor (an authenticator code, a passkey or a security key; an emailed code does not count). Each use writes a `plaintext.reveal` entry to the audit log **before** anything is shown, and counts against the same per-person reveal budget as showing a secret's value (60 per 5 minutes). If the audit entry cannot be written, or the budget cannot be counted, the request is refused rather than served. The Management API's request logs (`/v1/logs`) return metadata only and never a captured payload. A tenant policy that denies `log:read_payload` still refuses (with `"code": "reveal_policy_denied"`) before your verification is spent. ## Troubleshooting ### Issue: "No logs displayed" **Causes:** * No requests in time range * Filters too restrictive * Logs expired (past retention period) **Fix:** 1. Clear all filters 2. Expand time range 3. Check if route is active ### Issue: "Can't export logs" **Cause:** Starter plan (export requires Pro/Enterprise) **Fix:** Upgrade subscription plan ### Issue: "Missing request details" **Cause:** Log entry truncated (very large request/response) **Details stored:** * Headers: All * Body: First 10KB * Response: First 10KB **If truncated:** See "(truncated)" indicator ### Issue: "Logs show wrong timezone" **Fix:** 1. Navigate to Settings → Profile 2. Update timezone 3. Logs update automatically ## Best Practices ### 1. Regular Log Reviews **Weekly:** * Review error logs (4xx, 5xx) * Identify patterns * Fix recurring issues ### 2. Export for Long-Term Storage **Monthly:** * Export last 30 days * Archive to secure storage * Maintain audit trail ### 3. Use Filters to Reduce Noise **Focus on what matters:** * Filter out successful requests (when debugging) * Filter specific routes (when investigating) * Use latency filters (performance analysis) ### 4. Combine with Alerts **Workflow:** 1. Alert fires (e.g., high error rate) 2. Click "View Logs" from alert 3. See filtered logs that triggered alert 4. Investigate root cause ### 5. Monitor Unauthorized Attempts **Security practice:** * Weekly review of 401/403 errors * Identify patterns (IP ranges, times) * Block malicious actors * Alert on unusual activity ## Related Features * **Alerts**: Set up alerts based on log patterns * **Analytics**: Aggregate log data into metrics * **Audit Logs**: Track configuration changes (separate from API logs) ## Next Steps Configure alerts from log patterns View aggregate metrics Track configuration changes Configure routes to monitor *** * **Level**: beginner * **Time**: 10 minutes `logs`, `monitoring`, `debugging`, `audit`, `export` # Audit Logs Source: https://docs.knoxcall.com/monitoring/audit-logs Track all configuration changes to routes, secrets, clients, environments, and alerts. See who made what changes when for compliance and troubleshooting. # Audit Logs Track every configuration change made in KnoxCall with complete audit trail for compliance, security, and troubleshooting. ## Critical Audit Chain For sensitive operations — BYOK KMS management, secret store migrations, and tenant impersonation — KnoxCall maintains a **separate append-only audit log** (`audit_log_critical`) in addition to the standard audit trail. Entries in the critical chain are: * **SHA-256 hash-linked** — each entry includes a `prev_hash` (SHA-256 of the prior entry's canonical bytes) and a monotonic `sequence_number`. Tampering with any entry breaks its hash and every subsequent `prev_hash` link, making the tampered row identifiable. * **Append-only forever** — database triggers prevent UPDATE and DELETE at the Postgres level. There is no retention pruning on this table. * **Serialized writes** — an advisory lock on every write ensures monotonic, gap-free `sequence_number` values; a gap indicates a deletion attempt. This chain covers all 15 actions across five domains: | Domain | Actions | | - | - | | BYOK KMS | `byok.kms.onboard`, `byok.kms.rotate`, `byok.kms.unseal`, `byok.kms.revoke` | | Migration lifecycle | `migration.create`, `migration.cancel`, `migration.commit`, `migration.credentials.expired` | | Migration item review | `migration.item.reveal`, `migration.item.approve`, `migration.item.reject`, `migration.item.expire` | | Migration infrastructure | `migration.db_proxy_route.create`, `migration.db_proxy_route.delete` | | Impersonation | `tenant.impersonate` | For the full chain schema, canonical serialization spec, and verification endpoint, see the [Audit Logs API reference](/api-reference/audit-logs#sha-256-hash-chain--critical-audit-chain). ## Overview **Audit Logs** record all changes to your KnoxCall configuration: * 👤 Who made the change (user email) * 📝 What was changed (resource type and name) * ⏰ When it happened (timestamp) * 🔄 What changed (before/after values) * 🎯 Action performed (create, update, delete, enable, disable) **Not the same as API Logs:** * **API Logs** = Requests proxied through routes * **Audit Logs** = Changes to KnoxCall configuration ## What's Tracked ### Routes * ✅ Route created * ✏️ Route updated (target URL, headers, body, etc.) * 🗑️ Route deleted * 🔓 Route enabled/disabled * 🔄 Route duplicated ### Secrets * ✅ Secret created * 🔄 Secret version added (rotation) * ✏️ Secret name updated * 🗑️ Secret deleted ### Clients * ✅ Client created (IP address added) * ✏️ Client updated (IP changed, type changed) * 🗑️ Client deleted * 🔓 Client enabled/disabled * 🔗 Client assigned to route * ❌ Client unassigned from route ### Environments * ✅ Environment created * ✏️ Environment updated (overrides changed) * 🗑️ Environment deleted * 🔄 Environment duplicated ### Alerts * ✅ Alert created * ✏️ Alert updated (conditions, channels) * 🗑️ Alert deleted * 🔓 Alert enabled/disabled ### API Keys * ✅ API key created * 🗑️ API key revoked ### Tenant Settings * ✏️ Tenant name/slug updated * ⚙️ Feature settings changed ### Team Members * ✅ Team member invited * 🗑️ Team member removed * ✏️ Team member role changed ### Tenant KMS / BYOK * 🔑 Customer KMS provider onboarded (`byok.kms.onboard`) * 🔄 Tenant master key rotated (`byok.kms.rotate`) * 🔓 Tenant unsealed (`byok.kms.unseal`) * 💣 Tenant master key version revoked — cryptographic erasure (`byok.kms.revoke`) ### Secret Store Migrations * ✅ Migration created (`migration.create`) * ❌ Migration cancelled (`migration.cancel`) * 🔍 Migration item plaintext revealed to reviewer (`migration.item.reveal`) * ✔️ Migration item approved by reviewer (`migration.item.approve`) * ✘ Migration item rejected by reviewer (`migration.item.reject`) * ⏱️ Migration item expired after 7-day TTL (`migration.item.expire`) * ✅ Migration committed as complete (`migration.commit`) * 🔌 DB proxy route created for migration (`migration.db_proxy_route.create`) * 🗑️ DB proxy route deleted (`migration.db_proxy_route.delete`) ### Tenant Impersonation * 👤 Admin impersonated tenant (`tenant.impersonate`) — support actions only ## Viewing Audit Logs ### Step 1: Navigate to Audit Logs 1. Click **Logs** in the sidebar 2. Select **Audit Logs** 3. See recent changes (most recent first) ### Step 2: Use Filters **Filter by Action:** ```text theme={"dark"} ☐ Create ☐ Update ☐ Delete ☐ Enable ☐ Disable ``` **Filter by Resource Type:** ```text theme={"dark"} ☐ Route ☐ Secret ☐ Client ☐ Environment ☐ Alert ☐ API Key ☐ Tenant ☐ Team Member ☐ Tenant KMS Config ☐ Migration ``` **Filter by User:** Search by email address **Filter by Resource Name:** Search for specific route, secret, etc. **Filter by Environment:** Show changes to specific environment **Filter by Client/Secret:** Show changes related to specific client or secret ### Step 3: View Change Details **Click any audit log entry** to see full details: **Overview:** ```text theme={"dark"} Action: Route Updated Resource: stripe-payments (route_abc123) User: john@example.com Timestamp: 2025-01-15 10:30:45 EST ``` **Changes (diff view):** ```diff theme={"dark"} target_url: - https://api.stripe.com/v1/charges + https://api.eu.stripe.com/v1/charges inject_headers_json: Authorization: - Bearer {{secret:stripe-test-key}} + Bearer {{secret:stripe-prod-key}} ``` **Context:** ```text theme={"dark"} IP Address: 203.45.67.89 User Agent: Mozilla/5.0 (Windows NT 10.0) Session ID: sess_xyz789 ``` ## Common Use Cases ### Use Case 1: Troubleshooting Configuration Issues **Scenario:** Route suddenly stopped working **Investigation:** 1. Navigate to Audit Logs 2. Filter by Resource Type: Route 3. Filter by Resource Name: broken-route 4. See recent changes: ``` 2 hours ago: john@example.com updated target_url Old: https://api.service.com/v1 New: https://api.service.com/v2 (typo: should be v1) ``` 5. Identify issue: Incorrect URL 6. Fix: Revert URL to v1 ### Use Case 2: Security Audit **Scenario:** Quarterly compliance audit **Steps:** 1. Export audit logs for last 90 days 2. Filter by Action: Delete, Disable 3. Review all deletions/disablements 4. Verify authorized by appropriate users 5. Document for auditor ### Use Case 3: Secret Rotation Tracking **Scenario:** Verify secrets rotated per policy **Steps:** 1. Filter by Resource Type: Secret 2. Filter by Action: Update (new version) 3. Check last rotation date for each secret 4. Identify secrets due for rotation 5. Rotate overdue secrets ### Use Case 4: Unauthorized Changes **Scenario:** Suspect unauthorized configuration changes **Steps:** 1. Filter by User: (specific team member) 2. Review all changes by that user 3. Check timestamps (after hours?) 4. Verify changes were authorized 5. Take action if unauthorized ### Use Case 5: Change Correlation **Scenario:** Error rate spiked, find related change **Steps:** 1. Note time of spike: 10:30 AM 2. Filter audit logs by time: 10:00-10:45 AM 3. See changes during that window: ``` 10:25 AM: sarah@example.com updated stripe-payments route Changed: rate_limit from 100/min to 10/min ``` 4. Identified: Rate limit too restrictive 5. Fix: Revert rate limit ## Deep Links **Jump to audit logs from:** **Route Details:** * "View History" button → Audit logs for that route **Secret Details:** * "View History" button → Secret rotation history **Client Details:** * "View History" button → Client assignment changes **Alert Details:** * "View History" button → Alert configuration changes ## Audit Log Entry Details ### Full Information Captured **User context:** * Email address * User ID * IP address (where request originated) * Session ID **Resource context:** * Resource type (route, secret, client, etc.) * Resource ID * Resource name * Tenant ID **Change details:** * Action (create, update, delete, etc.) * Timestamp (with timezone) * Before values (for updates) * After values (for updates) * Full diff (JSON format) **Example JSON:** ```json theme={"dark"} { "id": "audit_abc123", "action": "update", "resource_type": "route", "resource_id": "route_xyz789", "resource_name": "stripe-payments", "user_id": "user_def456", "user_email": "john@example.com", "timestamp": "2025-01-15T15:30:45Z", "ip_address": "203.45.67.89", "changes": { "target_url": { "old": "https://api.stripe.com/v1", "new": "https://api.eu.stripe.com/v1" }, "rate_limit_requests": { "old": 100, "new": 150 } } } ``` ## Export Options ### CSV Export (Pro/Enterprise Only) **What's exported:** * Timestamp * User email * Action * Resource type * Resource name * Summary of changes **Steps:** 1. Apply filters to select logs 2. Click **Export** button 3. CSV downloads automatically **File name format:** ```text theme={"dark"} audit-logs-{tenant-slug}-{timestamp}.csv ``` ### JSON Export (Enterprise Only) **Full audit trail:** * Complete JSON for each entry * Includes before/after values * All metadata preserved **Use case:** Import to SIEM tools, compliance systems ## Retention Policies **By subscription plan (standard `audit_logs` table):** | Plan | Retention Period | | - | - | | Free | 7 days | | Starter | 7 days | | Pro | 30 days | | Enterprise | 90 days (extensible per contract — 12 months or 7 years for HIPAA/SOC 2) | Older rows are pruned by a daily cron. The hash chain remains intact across pruning — rows are removed from the oldest end only, so verification of remaining rows continues to work. **Critical audit chain (`audit_log_critical`):** Not pruned. Records persist indefinitely regardless of plan. Export to long-term storage if needed before tenant deletion. **After standard retention:** * Audit logs automatically deleted * Export before expiration for permanent records ## Compliance Features ### SOC 2 / ISO 27001 Requirements **Audit logs support:** * ✅ Who: User identification * ✅ What: Resource and action * ✅ When: Timestamp with timezone * ✅ How: Change details (before/after) * ✅ Integrity: Immutable (cannot be edited) * ✅ Export: CSV/JSON for auditors ### GDPR Compliance **Data captured:** * User email addresses (can be pseudonymized) * IP addresses (can be anonymized) * Configuration changes (no PII) **Data deletion:** * Audit logs deleted per retention policy * Can export before deletion ## Best Practices ### 1. Regular Review **Weekly:** * Review all changes * Verify authorized * Identify unauthorized activity **Monthly:** * Export audit logs * Archive for compliance * Review trends ### 2. Document Standard Changes **Create change log:** ```text theme={"dark"} 2025-01-15: - Updated stripe-payments target URL for EU region - Rotated stripe-prod-key secret (scheduled) - Added client: production-server-03 ``` **Cross-reference with audit logs** for verification. ### 3. Monitor High-Risk Actions **Focus on:** * Deletions (routes, secrets, clients) * Disablements (routes, alerts) * Secret rotations (ensure scheduled) * API key revocations (verify authorized) ### 4. Use Filters for Investigations **Start broad, narrow down:** 1. All changes today 2. Filter by resource type (e.g., routes) 3. Filter by specific route 4. View detailed changes ### 5. Export for Compliance **Quarterly/Annual:** 1. Export full audit log history 2. Store securely (encrypted) 3. Provide to auditors when required 4. Maintain for legal retention period ## Troubleshooting ### Issue: "Audit log missing" **Causes:** * Change happened before audit logging enabled * Entry expired (past retention period) * System issue (rare) **Check:** * Verify retention period * Confirm timestamp within retention ### Issue: "Can't see changes by other users" **Cause:** You might be filtering by your user **Fix:** Clear user filter to see all users ### Issue: "Export button disabled" **Cause:** Starter plan (export requires Pro/Enterprise) **Fix:** Upgrade subscription plan ### Issue: "Diff not showing" **Cause:** Change type doesn't support diff (e.g., delete action) **Expected:** Create and Delete actions don't have before/after diff ## Related Features * **API Logs**: Track API request activity (different from config changes) * **Alerts**: Set up alerts for specific configuration changes (coming soon) * **Team Management**: Control who can make changes ## Next Steps View API request logs Manage team access Configure routes Manage secrets *** * **Level**: beginner * **Time**: 10 minutes `audit`, `compliance`, `security`, `tracking`, `governance` # Client Tracking & Health Monitoring Source: https://docs.knoxcall.com/monitoring/client-tracking Real-time client health monitoring with network ping status, connection diagnostics, and usage analytics. Track every API client's status and performance. # Client Tracking & Health Monitoring Monitor the health, connectivity, and usage of every API client in real-time. KnoxCall provides comprehensive client tracking with network diagnostics, ping monitoring, and detailed usage analytics. ## What is Client Tracking? Client tracking gives you visibility into every application, server, or service that accesses your APIs through KnoxCall: * 📊 **Usage Analytics** - Requests, success rates, latency per client * 🏥 **Health Monitoring** - Ping status, connectivity checks * 🌍 **Geographic Tracking** - See where requests originate * 📈 **Performance Metrics** - Response times and error rates * 🔔 **Alerting** - Get notified of client issues ## Client Overview Dashboard The client dashboard shows all your clients at a glance: ```text theme={"dark"} ╔══════════════════════════════════════════════════════════════╗ ║ CLIENT STATUS OVERVIEW ║ ╠══════════════════════════════════════════════════════════════╣ ║ Total Clients: 12 ║ ║ 🟢 Healthy: 10 🟡 Degraded: 1 🔴 Down: 1 ║ ║ Total Requests (24h): 156,489 ║ ║ Average Latency: 45ms ║ ╚══════════════════════════════════════════════════════════════╝ ┌──────────────────────────────────────────────────────────────┐ │ CLIENT LIST │ ├──────────────────────────────────────────────────────────────┤ │ 🟢 mobile-app-ios Last seen: 2m ago Requests: 45k│ │ 🟢 web-frontend Last seen: 5s ago Requests: 78k│ │ 🟡 payment-processor Last seen: 1h ago Requests: 234│ │ 🔴 legacy-system Last seen: 2d ago Requests: 0 │ │ 🟢 internal-api Last seen: 10s ago Requests: 12k│ │ 🟢 partner-integration Last seen: 1m ago Requests: 21k│ └──────────────────────────────────────────────────────────────┘ ``` ## Client Health Status Each client has a health status: ### 🟢 Healthy ```text theme={"dark"} Criteria: - ✅ Last seen < 5 minutes ago - ✅ Success rate > 95% - ✅ Latency < 500ms - ✅ No recent errors ``` ### 🟡 Degraded ```text theme={"dark"} Criteria: - ⚠️ Success rate 80-95% - ⚠️ Latency 500-2000ms - ⚠️ Minor errors present - ⚠️ Last seen 5-60 minutes ago ``` ### 🔴 Down ```text theme={"dark"} Criteria: - ❌ Last seen > 1 hour ago - ❌ Success rate < 80% - ❌ Multiple consecutive errors - ❌ Connection timeout ``` ### ⚪ Unknown ```text theme={"dark"} Criteria: - No requests yet - Newly created client - Pending first connection ``` ## Real-Time Ping Monitoring KnoxCall actively monitors client health with ping checks: ### Active Ping Monitoring For IP-based clients, KnoxCall can actively ping: ```text theme={"dark"} Every 60 seconds: ├─ ICMP ping to client IP ├─ Measure round-trip time ├─ Detect connectivity issues └─ Update health status ``` **Enable for client:** 1. Go to **Clients** → Select client 2. Enable **Active Health Checks** 3. Configure: ```yaml theme={"dark"} Check Interval: 60 seconds Timeout: 5 seconds Failure Threshold: 3 consecutive failures Protocol: ICMP Ping ``` ### Passive Monitoring For all clients, track based on API requests: ```text theme={"dark"} Monitor: - Last request timestamp - Request frequency - Success/failure patterns - Response time trends ``` ## Client Details View Click any client to see detailed information: ### Overview Tab ```text theme={"dark"} ╔══════════════════════════════════════════════════════════════╗ ║ CLIENT: mobile-app-ios ║ ╠══════════════════════════════════════════════════════════════╣ ║ Status: 🟢 Healthy ║ ║ Type: Mobile Application ║ ║ IP Address: 203.0.113.50 ║ ║ Last Seen: 2 minutes ago ║ ║ Created: 2024-08-15 ║ ╚══════════════════════════════════════════════════════════════╝ Request Statistics (24 hours): ├─ Total Requests: 45,234 ├─ Successful: 44,987 (99.45%) ├─ Failed: 247 (0.55%) ├─ Average Latency: 42ms └─ Peak Requests/hour: 3,456 Geographic Distribution: ├─ 🇺🇸 United States: 78% ├─ 🇨🇦 Canada: 15% ├─ 🇬🇧 United Kingdom: 5% └─ 🇦🇺 Australia: 2% ``` ### Activity Timeline See client activity over time: ```text theme={"dark"} 24-Hour Activity: 12 AM ████░░░░░░░░░░░░░░░░ 1,234 req 1 AM ███░░░░░░░░░░░░░░░░░ 876 req 2 AM ██░░░░░░░░░░░░░░░░░░ 654 req ... 11 PM ████████░░░░░░░░░░░░ 2,345 req Peak: 8 AM - 12 PM (business hours) Low: 1 AM - 5 AM (night hours) ``` ### Performance Metrics Track client performance: ```text theme={"dark"} Response Time Distribution: < 50ms: ████████████████████ 85% 50-100ms: ████░░░░░░░░░░░░░░░░ 10% 100-500ms:██░░░░░░░░░░░░░░░░░░ 4% > 500ms: ░░░░░░░░░░░░░░░░░░░░ 1% Error Types: 404 Not Found: 45% (112 errors) 503 Service Unavailable: 30% (74 errors) 401 Unauthorized: 15% (37 errors) 500 Internal Error: 10% (24 errors) ``` ### Routes Used See which routes this client accesses: ```text theme={"dark"} Route Usage: ├─ user-api............45% (20,355 req) ├─ order-api...........30% (13,570 req) ├─ product-api.........20% (9,047 req) └─ payment-api..........5% (2,262 req) ``` ### Recent Requests View last 100 requests: ```text theme={"dark"} [15:34:22] GET /api/users/123 → 200 OK (45ms) [15:34:18] POST /api/orders → 201 Created (89ms) [15:34:10] GET /api/products → 200 OK (32ms) [15:33:55] DELETE /api/cart/456 → 204 No Content (28ms) [15:33:42] POST /api/auth/login → 401 Unauthorized (12ms) ... ``` ## Connection Diagnostics Diagnose connectivity issues: ### Network Path Trace See the network path to your client: ```text theme={"dark"} Traceroute to 203.0.113.50: 1. knoxcall-gateway (0.2ms) 2. provider-edge (2.5ms) 3. internet-backbone (15.3ms) 4. client-isp (32.1ms) 5. client-network (45.7ms) 6. 203.0.113.50 (48.2ms) Total hops: 6 Total latency: 48.2ms Status: ✅ Healthy connection ``` ### Ping Statistics Real-time ping monitoring: ```text theme={"dark"} Ping Statistics (last hour): ├─ Packets sent: 60 ├─ Packets received: 60 ├─ Packet loss: 0% ├─ Min RTT: 42ms ├─ Avg RTT: 48ms ├─ Max RTT: 67ms └─ Jitter: 8ms Status: 🟢 Excellent ``` ### DNS Resolution Check DNS configuration: ```text theme={"dark"} DNS Lookup: mobile-app-ios.example.com ├─ Resolved IP: 203.0.113.50 ✅ ├─ TTL: 300 seconds ├─ DNS Server: 8.8.8.8 └─ Resolution time: 12ms Reverse DNS: └─ 203.0.113.50 → mobile-app-ios.example.com ✅ ``` ## Usage Analytics ### Request Volume Trends Track request patterns: ```text theme={"dark"} 7-Day Request Volume: Mon: ████████████████░░░░ 45.2k Tue: ████████████████████ 52.1k ← Peak Wed: ███████████████░░░░░ 48.7k Thu: ███████████████░░░░░ 47.3k Fri: ████████████████░░░░ 46.8k Sat: ████████░░░░░░░░░░░░ 28.4k Sun: ███████░░░░░░░░░░░░░ 24.9k Weekly Total: 293,400 requests Average: 41,914 req/day Trend: ↗ +12% vs last week ``` ### Success Rate Over Time Monitor reliability: ```text theme={"dark"} Success Rate (7 days): 100% ┤ ╭──── 99% ┤ ╭───────╯ 98% ┤ ╭───────╯ 97% ┤ ────╯ 96% ┤ 95% ┼──────────────────────────── Mon Tue Wed Thu Fri Current: 99.5% ✅ Target: 99% ✅ Trend: Improving ``` ### Top Error Codes Identify common issues: ```text theme={"dark"} Error Distribution (24h): 404 Not Found............112 (45%) └─ Most common path: /api/users/{deleted_id} 503 Service Unavailable...74 (30%) └─ Peak time: 2 PM - 3 PM (backend overload) 401 Unauthorized..........37 (15%) └─ Cause: Expired tokens 500 Internal Error........24 (10%) └─ Route: payment-api ``` ## Alerts & Notifications Set up client-specific alerts: ### Client Down Alert ```text theme={"dark"} Alert: mobile-app-ios Offline Condition: No requests for 15 minutes Notification: Email, Slack, SMS ``` ### High Error Rate ```text theme={"dark"} Alert: Degraded Client Performance Condition: Error rate > 5% for 10 minutes Client: payment-processor Notification: PagerDuty escalation ``` ### Unusual Activity ```text theme={"dark"} Alert: Abnormal Request Volume Condition: Request volume 10x normal Client: partner-integration Action: Rate limit + notify ``` ## Client Comparison Compare multiple clients side-by-side: ```text theme={"dark"} mobile-app web-frontend legacy-system Request Volume 45.2k 78.3k 234 Success Rate 99.5% 98.7% 85.2% Avg Latency 42ms 38ms 1,245ms Last Active 2m ago 5s ago 2d ago Status 🟢 🟢 🔴 ``` ## Geographic Tracking See where requests come from: ### World Map View ```text theme={"dark"} Client: mobile-app-ios - Geographic Distribution 🌎 North America: 93% ├─ 🇺🇸 United States: 78% └─ 🇨🇦 Canada: 15% 🌍 Europe: 5% └─ 🇬🇧 United Kingdom: 5% 🌏 Asia Pacific: 2% └─ 🇦🇺 Australia: 2% ``` ### City-Level Detail ```text theme={"dark"} Top Cities: 1. New York, NY: 23,456 req (52%) 2. Los Angeles, CA: 8,934 req (20%) 3. Toronto, ON: 5,678 req (13%) 4. London, UK: 2,234 req (5%) 5. San Francisco, CA: 1,890 req (4%) ``` ## Client Lifecycle Management Track client lifecycle: ```text theme={"dark"} Timeline: 2024-08-15: Created 2024-08-16: First request 2024-08-20: Production rollout (10k req/day) 2024-09-01: Upgrade v2.0 (20k req/day) 2024-10-15: Peak usage (52k req/day) 2024-10-29: Current (45k req/day) Age: 76 days Total Requests: 2.8M Avg Daily: 37k requests ``` ## Best Practices ### 1. Name Clients Descriptively ```text theme={"dark"} ✅ Good: - mobile-app-ios-v2 - web-frontend-production - partner-api-acme-corp ❌ Bad: - client1 - test - temp ``` ### 2. Tag Clients by Purpose ```text theme={"dark"} Tags: - mobile - production - payment-critical - partner-api ``` ### 3. Monitor Critical Clients Set up monitoring for business-critical clients: ```text theme={"dark"} Critical Clients: - payment-processor ← Alert on any issue - order-fulfillment ← Strict SLA requirement - customer-portal ← High visibility ``` ### 4. Review Inactive Clients Regularly audit inactive clients: ```text theme={"dark"} Inactive > 30 days: - Remove: legacy-system-old - Archive: test-client-2023 - Keep: backup-system (standby) ``` ### 5. Document Client Ownership ```text theme={"dark"} Client: mobile-app-ios Owner: Mobile Team Contact: mobile-team@company.com On-Call: +1-555-123-4567 ``` ## Troubleshooting ### Client shows as down but is working **Causes:** * Client using different IP * Requests going through proxy * Client ID mismatch **Fix:** * Check client configuration * Verify IP whitelisting * Review recent requests ### High latency for one client **Diagnose:** * Run traceroute * Check ping statistics * Compare with other clients * Review backend performance ### Intermittent connectivity **Check:** * Network path stability * Packet loss percentage * Client-side issues * ISP problems ## Next Steps Set up client health alerts View detailed analytics Configure client security Set per-client rate limits *** * **Level**: intermediate * **Time**: 15 minutes `monitoring`, `clients`, `health-checks`, `tracking`, `diagnostics` # OpenTelemetry Export (OTLP) Source: https://docs.knoxcall.com/monitoring/opentelemetry Stream KnoxCall request traces and integration logs to your own OpenTelemetry collector or SIEM — Grafana, Datadog, Elastic, SigNoz, or Splunk — using the OTLP/HTTP protocol with standard semantic conventions. # OpenTelemetry Export (OTLP) KnoxCall's integration logging is aligned to **OpenTelemetry standards**. Every request KnoxCall proxies and every webhook it delivers can be exported, in real time, to your own observability backend over the standard **OTLP/HTTP** protocol — so the same event stream you see in the dashboard lands in your SIEM with no custom glue. ## What gets exported When an OTLP endpoint is configured, KnoxCall emits three signals: ### Traces One **`SERVER` span per HTTP request** through the platform, plus dedicated spans for AI Gateway calls (using the OpenTelemetry [GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/)) and security events. Spans use the standard HTTP attributes (`http.request.method`, `url.path`, `http.response.status_code`) with KnoxCall-specific context under the `knoxcall.*` namespace. KnoxCall participates in **W3C distributed tracing**: * Inbound `traceparent` headers are honoured, so a request that arrives already traced continues the **same** trace. * Outbound calls — both the proxied upstream request and webhook deliveries — carry an injected `traceparent`, so the trace extends end-to-end through KnoxCall into your upstream APIs. ### Logs (integration logs) Every proxied request and every webhook delivery is emitted as an **OTLP `LogRecord`**: | Event | `event.name` | Key attributes | | - | - | - | | Proxied API request | `knoxcall.proxy.request` | `http.request.method`, `url.path`, `http.response.status_code`, `server.address`, `knoxcall.request_id`, `knoxcall.tenant_id`, `knoxcall.route_name`, `knoxcall.latency_ms` | | Webhook delivery | `knoxcall.webhook.delivery` | `http.request.method`, `http.response.status_code`, `server.address`, `knoxcall.webhook.event_type`, `knoxcall.webhook.success`, `knoxcall.webhook.retry_count` | Severity maps from outcome: `2xx/3xx → INFO`, `4xx → WARN`, `5xx` or transport error `→ ERROR`. Log records carry the **structured envelope** (method, path, status, IDs, latency) — not request or response bodies. Full payloads remain (obfuscated) in the [API Logs](/monitoring/api-logs) store, so the OTLP stream stays cheap to ship and index. ### Metrics (analytics) Per-request analytics are exported as OTLP metrics on a 60-second interval, so request volume, error rate, and latency show up as native dashboards/monitors in your backend: | Instrument | Type | Unit | Dimensions | | - | - | - | - | | `knoxcall.proxy.requests` | Counter | `{request}` | `http.response.status_code`, `http.response.status_class`, `knoxcall.route_name`, `knoxcall.environment`, `error` | | `knoxcall.proxy.request.duration` | Histogram | `ms` | (same) | On the operator export these carry a `knoxcall.tenant_id` dimension; on a per-tenant export (below) they don't, since every point already belongs to that tenant. ## Configuring the endpoint There are two ways to point KnoxCall at a collector. **Environment variables take precedence** when set; otherwise the admin-UI integration is used. ### Option 1 — Admin UI (recommended for self-hosted) 1. Go to **Settings → Integrations** and switch to the **Global** scope (admins only; available on both cloud and self-hosted deployments). 2. Open **OpenTelemetry export**. 3. Fill in: * **OTLP HTTP endpoint** — the base URL of your collector, e.g. `https://otlp.your-collector.com`. KnoxCall appends `/v1/traces`, `/v1/logs`, and `/v1/metrics`. * **Service name** *(optional)* — the resource `service.name` (defaults to `knoxcall`). * **Auth headers** *(optional, encrypted at rest)* — comma-separated `key=value` pairs, e.g. `Authorization=Bearer , X-Scope-OrgID=acme`. * **Export traces / logs / metrics** — per-signal toggles (all on by default); turn off any signal you don't want shipped. 4. Save. Export activates immediately — **no restart required**. ### Option 2 — Environment variables | Variable | Purpose | | - | - | | `OTEL_EXPORTER_OTLP_ENDPOINT` | Base OTLP/HTTP endpoint (enables export) | | `OTEL_EXPORTER_OTLP_HEADERS` | Auth headers, `key=value,key2=value2` | | `OTEL_SERVICE_NAME` | Resource `service.name` (default `knoxcall`) | | `OTEL_TRACES_EXPORTER=none` | Disable the traces signal only | | `OTEL_LOGS_EXPORTER=none` | Disable the logs signal only | When neither an env endpoint nor the integration is configured, export is fully off and adds no overhead. ## Compatible backends Any OTLP/HTTP-compatible collector or backend, including: * **Grafana** (Tempo for traces, Loki for logs) / Grafana Cloud * **Datadog** (OTLP intake) * **Elastic** APM * **SigNoz** * **Splunk** (via the OpenTelemetry Collector) * A self-run **OpenTelemetry Collector** that fans out to anything downstream ## Operator export vs per-tenant export There are two independent OTLP destinations, for two different audiences: * **Operator export** (this page's *OpenTelemetry (OTLP export)* card, **Global** scope, or the env vars). Ships the **whole gateway's** traces + logs + metrics to the operator's collector — for whoever runs the deployment to monitor KnoxCall itself. Operator metrics/logs carry a `knoxcall.tenant_id` dimension so you can facet by tenant. * **Per-tenant export** (the *Telemetry export (OTLP)* integration, tenant scope). Each tenant points KnoxCall at **their own** collector/SIEM and receives **only their own** request logs and analytics metrics — nothing from other tenants, and no traces. This is configured per workspace, right next to Email/Twilio/S3 in **Settings → Integrations**. Both can run at once: a request fans out to the operator's collector **and** the originating tenant's collector. ### Configure per-tenant export 1. As a workspace admin, go to **Settings → Integrations → Telemetry export (OTLP)**. 2. Set the **OTLP HTTP endpoint** (your collector — e.g. a Datadog Agent or OTel Collector), optional **service name**, and optional **auth headers** (encrypted at rest). 3. Save. Only that workspace's `knoxcall.proxy.request` logs and `knoxcall.proxy.requests` / `knoxcall.proxy.request.duration` metrics ship to it; applies on the next request, no restart. ## Error tracking (Sentry) The OTLP signals above are for shipping telemetry to your own collector. To **catch errors** — with native grouping, alerting, and release health — KnoxCall ships an optional [Sentry](https://sentry.io) integration covering both tiers: * **Browser** (`@sentry/react`) — admin-UI uncaught exceptions, React render crashes, and unhandled promise rejections. * **Operator console** (`@sentry/react`, separate DSN) — the same for the console SPA. Its own project on purpose: console events identify a KnoxCall operator and carry no `tenant_id`, so it can be switched on independently of the tenant-facing admin UI, and its issue stream kept behind tighter access. * **Backend** (`@sentry/node`) — server-side exceptions on any `5xx`, plus uncaught exceptions and unhandled rejections outside the request cycle, in **every** process: the API server, the standalone proxy, the workflow workers and each scheduled job. Events carry a `component` tag naming which one (`api`, `proxy`, `workflow-worker`, `cron:retention-purge`, …), so a crash in a 03:00 job is as visible as one on the request path. It's a **separate** integration card — **Error tracking (Sentry)** — independent of the OpenTelemetry export above. Configure either, both, or neither. ### Configure 1. Create Sentry project(s) and copy the **DSN(s)** (`https://@o0.ingest.sentry.io/0`). A DSN identifies a project, not a secret. Using **separate browser and server projects is recommended** (cleaner source maps, releases, and access control), but one project for both works too. 2. In **Settings → Integrations** (Global scope) → **Error tracking (Sentry)**: * **Sentry DSN — browser (admin UI errors)** → the browser project DSN. * **Sentry DSN — server (backend errors)** → the backend project DSN. * **Sentry DSN — operator console** → the console project DSN (optional; a separate project is recommended, see above). * Save. (Or set `SENTRY_DSN` / `SENTRY_DSN_BACKEND` env vars — env wins, same as the OTLP endpoint. `SENTRY_ENVIRONMENT` sets the environment tag.) 3. The browser DSNs are delivered to their SPA shells at page-load time (no rebuild) — the admin DSN only to the admin UI, the console DSN only to `console.knoxcall.com`. The backend DSN stays server-side and applies immediately on save. When a DSN isn't set, that SDK never initialises and adds no overhead. ### What it captures, and what it deliberately doesn't * **Captures:** browser uncaught errors / rejections / React render errors (with the component stack); backend `5xx` exceptions and process-level crashes — tagged with release and environment. Backend errors carry the opaque `errorId` that's also in the server log and the client response, for correlation. * **Tenant-filterable:** events are tagged with `tenant_id` (and identified by user **UUID** only — never email/name) on both tiers, so you can filter "all errors for tenant X" in Sentry. These are internal identifiers, not PII, but they are a data category sent to Sentry — see the note below. * **No session replay.** Replay records the live admin DOM, which on a multi-tenant console can capture other tenants' data — off by design. * **No PII.** Cookies, IPs, request bodies and URL query parameters are never attached — every Sentry SDK sets `dataCollection` explicitly to collect none of them, keeping only the browser's User-Agent. Token/secret-shaped values are redacted, and query strings and URL fragments stripped, from event messages, breadcrumbs and page URLs before an event leaves the process or the browser; the backend reports the request **path** only. * **Optional: warn+ logs.** Set `SENTRY_LOGS=1` to ship warn and error lines from the application logger to Sentry as structured logs, attached alongside the exceptions. Worth it because the backend SDK carries no breadcrumbs by design, so a stack trace otherwise arrives with no run-up. Only text that has already been through the redaction grammar is sent — Sentry's `consoleLoggingIntegration` is deliberately **not** used, as it would ship un-redacted `console.*` output. Off by default: log ingest is billed by volume, and it widens what leaves the process from exceptions to log bodies. * **No tracing interference.** The backend SDK runs as a pure error reporter (no Sentry OpenTelemetry setup, no loader hook, no auto-instrumentation) — OpenTelemetry above keeps owning all traces and propagation. (Backend errors are therefore not OTel-trace-linked; the traces live in your OTLP backend.) ### Browser source maps Without source maps a browser stack trace is minified frames against hashed chunk names. Set `SENTRY_ORG`, `SENTRY_PROJECT` and `SENTRY_AUTH_TOKEN` on the machine that builds `admin-ui`, and the build uploads maps to Sentry and then deletes them from `dist/`. The console build reads the same variables, with `SENTRY_PROJECT_CONSOLE` overriding `SENTRY_PROJECT` when the two SPAs report to separate projects. These are **build-time** variables, not runtime config — they do not belong in the server env file. A build without all three emits no maps at all. That is deliberate: an un-uploaded map left in `dist/` would serve readable admin-console source from the admin origin, which is a worse outcome than unsymbolicated traces. ### Sentry as an OTLP backend You can point the **OTLP HTTP endpoint** at Sentry's own OTLP ingest, so spans and integration logs land in the same project as your errors. Copy the values from **Project Settings → OpenTelemetry** in Sentry: | Setting | Value | | - | - | | **OTLP HTTP endpoint** | `https://o.ingest..sentry.io/api//integration/otlp` | | **OTLP auth headers** | `x-sentry-auth=sentry sentry_key=` | Give the endpoint without a signal suffix — KnoxCall appends `/v1/traces` and `/v1/logs` itself, which is exactly what Sentry expects. **Sentry does not ingest OTLP metrics.** Send them somewhere else, or turn them off — otherwise the metric exporter POSTs to a path Sentry rejects every 60 seconds for as long as the process runs. Traces and logs are unaffected. #### Sending one signal somewhere else Each signal can override the endpoint above, using the standard OpenTelemetry variable names: ```bash theme={"dark"} OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=https://your-metrics-backend/otlp/v1/metrics OTEL_EXPORTER_OTLP_METRICS_HEADERS=Authorization=Bearer ``` Two rules, both from the OTel spec and both deliberate: * A **signal endpoint is used verbatim** — it already carries its path, so give the full URL ending in `/v1/metrics`. Nothing is appended. * **Signal headers replace the generic ones**, they do not merge. That is what keeps your Sentry key out of requests to the metrics backend, and the metrics backend's token out of requests to Sentry. The same works for `TRACES` and `LOGS`. Signals with no override keep using the endpoint and headers above. These are env-only; the Integrations tab keeps a single endpoint field, since splitting signals across vendors is an operator concern rather than a self-host one. To simply turn metrics off instead, untick **Export metrics** or set `OTEL_METRICS_EXPORTER=none`. Two things this does *not* replace. Sentry's OTLP ingest is in open beta and is thinner on grouping and breadcrumbs than the native SDK, so keep the **Sentry DSN — server** field set for errors. And OTLP retention is a Sentry plan setting measured in weeks, so it is a place to alert and triage from, not a system of record — audit evidence belongs in the audit log and its offsite archive. Configuring a Sentry DSN sends error reports to Sentry. For the hosted service that makes Sentry a **sub-processor**; if you run KnoxCall yourself the data goes to **your** Sentry project. Either way, review your sub-processor disclosures before enabling it in production. ## Related * [API Request Logs](/monitoring/api-logs) — full request/response history in the dashboard * [Audit Logs](/monitoring/audit-logs) — security and configuration events (also deliverable via the `audit.event` webhook) * [Webhooks](/webhooks/overview) — push individual events to your own endpoints # Real-Time Geographic Tracking Source: https://docs.knoxcall.com/monitoring/realtime-geo Watch live API requests animated on an interactive world map. See request origins, destinations, status codes, and latency in real-time with audio feedback. # Real-Time Geographic Tracking Watch your API requests in real-time on an interactive world map with animated arcs showing traffic flow from source to destination. ## Overview The **Real-Time Geo** feature provides live visualization of API requests: * 🌍 **Interactive World Map** - Leaflet-powered map with smooth animations * ✈️ **Animated Request Arcs** - Visual paths from client to backend * 🎵 **Audio Feedback** - Ping sound on each request (optional) * 📍 **City-Level Precision** - See exact cities for requests * 🎨 **Color-Coded Status** - Green (success), Red (error), Yellow (redirect) * 📊 **Live Statistics** - Requests/minute, active routes **Perfect for:** * Live demos and presentations * Monitoring global traffic patterns * Detecting geographic issues * Understanding user distribution ## What You'll See ### Animated Request Arcs Each API request draws an arc on the map: **Arc properties:** * **Start point**: Client IP location (latitude, longitude) * **End point**: Backend API location * **Color**: * 🟢 Green: Success (2xx status codes) * 🔴 Red: Error (4xx, 5xx status codes) * 🟡 Yellow: Redirect (3xx status codes) * **Animation**: Arc draws from source to destination over 2 seconds * **Label**: Shows method, path, latency **Example arc:** ```text theme={"dark"} From: New York, USA (40.7128°N, 74.0060°W) To: Virginia, USA (37.4316°N, 78.6569°W) Method: POST /api/payments Status: 200 OK (green arc) Latency: 145ms ``` ### Request Details Panel On the right side, see live request feed: **Each entry shows:** * 🌐 **Source**: City, Country * 🎯 **Destination**: City, Country * 📝 **Request**: HTTP method and path * ✅ **Status**: Status code with color indicator * ⏱️ **Latency**: Response time in milliseconds * 🕐 **Time**: Timestamp of request **Example:** ```text theme={"dark"} 🌐 London, United Kingdom → Virginia, USA 📝 GET /api/users/123 ✅ 200 (23ms) 🕐 10:30:45 ``` ### Live Statistics **Top bar displays:** * **Total Requests**: Cumulative count since page load * **Requests/Minute**: Current rate (rolling average) * **Active Routes**: Number of routes receiving traffic **Updates in real-time** as requests come in. ## Using Real-Time Geo ### Step 1: Navigate to Page 1. Click **Monitoring** in sidebar 2. Select **Real-Time Geo** 3. Map initializes and starts listening for requests ### Step 2: Enable Audio (Optional) **Toggle audio feedback:** 1. Click **Audio** toggle in top-right 2. Each request plays a ping sound 3. Useful for background monitoring **Sound characteristics:** * Frequency: 800Hz sine wave * Duration: 200ms * Volume: 30% (not too loud) ### Step 3: Watch Live Traffic **As requests come in:** 1. Marker appears at source location 2. Arc animates from source to destination 3. Request details appear in right panel 4. Statistics update 5. Audio ping plays (if enabled) ### Step 4: Interact with Map **Map controls:** * **Zoom**: Mouse wheel or +/- buttons * **Pan**: Click and drag * **Reset View**: Click home button **Request list:** * **Scroll**: View historical requests (last 50) * **Auto-scroll**: List scrolls to show newest requests ## Features ### SSE (Server-Sent Events) Connection **Real-time streaming:** * Persistent connection to server * Pushes new requests as they occur * Connection indicator shows status **Connection states:** * 🟢 **Connected**: Receiving live data * 🔴 **Disconnected**: Connection lost, retrying * 🟡 **Connecting**: Establishing connection **If disconnected:** * Automatic reconnection every 5 seconds * No data loss (buffered on server) ### Geographic Resolution **How locations determined:** 1. **Client IP** → GeoIP lookup → City, Country, Coordinates 2. **Backend URL** → DNS resolution → IP → GeoIP lookup 3. Cached for performance **Accuracy:** * City-level for most requests * Country-level if city unknown * Coordinates used for precise arc drawing ### Color Coding **Status code colors:** ```text theme={"dark"} 2xx (Success): Green arc 🟢 3xx (Redirect): Yellow arc 🟡 4xx (Client Error): Orange arc 🟠 5xx (Server Error): Red arc 🔴 ``` **Visual scanning:** * All green = everything working * Red arcs appearing = errors, investigate * Many yellow = redirects (might be configuration issue) ### Request Persistence **On-screen:** * Arcs fade after 5 seconds * Request list keeps last 50 requests * Statistics accumulate since page load **Refresh page** to reset counters and clear history. ## Use Cases ### Use Case 1: Live Demo **Scenario:** Presenting KnoxCall to stakeholders **Setup:** 1. Open Real-Time Geo on large monitor 2. Enable audio feedback 3. Generate some test traffic 4. Show animated arcs in real-time **Impact:** Visual wow factor demonstrates proxy in action. ### Use Case 2: Geographic Load Balancing **Scenario:** Verify multi-region setup **What to look for:** ```text theme={"dark"} US clients → US backend (green arcs) EU clients → EU backend (green arcs) Asia clients → Asia backend (green arcs) ``` **If seeing long arcs across continents:** * Possible routing misconfiguration * Clients not hitting nearest backend * Investigate environment overrides ### Use Case 3: Incident Response **Scenario:** Users in specific region reporting errors **Investigation:** 1. Open Real-Time Geo 2. Watch for red arcs from that region 3. Check destination backend 4. Verify latency values **Example finding:** ```text theme={"dark"} Red arcs from UK → Virginia backend (timeout) Issue: UK → US network path degraded Fix: Switch UK clients to EU backend ``` ### Use Case 4: Traffic Pattern Analysis **Scenario:** Understanding global user distribution **Observe over 1 hour:** * Which regions generate most traffic? * What times are peak for each region? * Are there unexpected traffic sources? **Example insights:** ```text theme={"dark"} 70% traffic from US (expected) 15% from EU (growing market) 10% from Asia (new clients) 5% from unknown IPs (investigate - possible bots) ``` ## Advanced Features ### Filter by Route **Coming soon:** Filter to show only specific routes **Use case:** Monitor traffic for single route during deployment ### Latency Heatmap Overlay **Map overlay shows:** * Color intensity = average latency * Identify slow regions * Plan infrastructure improvements **Access:** 1. Toggle **Heatmap** view 2. See color gradient (green = fast, red = slow) ### Historical Playback **Coming soon:** Replay past traffic **Use case:** Analyze incidents after they occurred ## Best Practices ### 1. Use for Live Monitoring **Ideal scenarios:** * Load testing (watch traffic scale) * Deployments (ensure no errors) * Marketing campaigns (see traffic spikes) * Conference demos (impressive visualization) ### 2. Enable Audio During Background Monitoring **When working on other tasks:** 1. Open Real-Time Geo on second monitor 2. Enable audio 3. Mute if no activity (silence = good) 4. Investigate when rapid pings (unusual activity) ### 3. Combine with Analytics Dashboard **Split screen setup:** * Left: Real-Time Geo (live visualization) * Right: Analytics (charts and metrics) **Comprehensive monitoring** - see both traffic flow and performance metrics. ### 4. Use for Client Onboarding **Show new clients:** 1. Their first test request 2. Arc animating across map 3. Successful green arc = integration working **Builds confidence** in KnoxCall proxy. ### 5. Screenshot for Reports **Monthly reports:** 1. Open Real-Time Geo during peak traffic 2. Screenshot showing global traffic 3. Include in monthly/quarterly reports 4. Visual representation of reach ## Troubleshooting ### Issue: "No requests appearing" **Causes:** * No active traffic to routes * SSE connection disconnected * Browser blocking SSE (firewall/proxy) **Fix:** 1. Check connection indicator (should be green) 2. Generate test request: ```bash theme={"dark"} # $TOKEN is a kc_… OAuth access token — see /api-reference/authentication curl -X GET "https://hash.tenant.knoxcall.com/test" \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: test-route" ``` 3. Verify request appears on map ### Issue: "Map not loading" **Causes:** * Leaflet CSS not loaded * JavaScript error * Feature not available on subscription plan **Fix:** 1. Refresh page 2. Check browser console for errors 3. Verify subscription includes Real-Time Geo ### Issue: "Locations incorrect" **Causes:** * GeoIP database outdated * VPN/proxy hiding real location * IP address not in GeoIP database **Expected behavior:** * VPN users show VPN exit location (not real location) * This is correct behavior ### Issue: "Audio not working" **Causes:** * Browser autoplay policy (requires user interaction) * Audio muted in browser * Audio toggle disabled **Fix:** 1. Click anywhere on page (activates audio context) 2. Enable audio toggle 3. Check browser audio settings ## Technical Details ### Performance **Optimization:** * Arcs removed after 5 seconds (prevent memory leak) * Request list limited to 50 entries * Coordinates cached (avoid repeated GeoIP lookups) **Browser requirements:** * Modern browser with SSE support * Canvas/SVG support for animations * Audio API support (optional, for audio feedback) ## Privacy Considerations **Data shown:** * Client city and country (not exact address) * Backend city and country * Request paths (may contain sensitive data) **Recommendations:** * Don't share screenshots if paths contain secrets * Use for internal monitoring only * Consider data privacy regulations (GDPR, etc.) ## Related Features * **Analytics**: View aggregate traffic metrics * **API Logs**: Detailed request history * **Alerts**: Set up geographic alerts (coming soon) ## Next Steps View aggregate metrics Detailed request logs Configure monitoring alerts Configure routes to track *** * **Level**: beginner * **Time**: 5 minutes `real-time`, `geographic`, `visualization`, `monitoring`, `live` # Go SDK Source: https://docs.knoxcall.com/sdks/go github.com/knoxcall/sdk-go — the official KnoxCall client for Go. OAuth 2.1 under the hood, zero dependencies (stdlib only). `github.com/knoxcall/sdk-go` is the official KnoxCall client for Go — stdlib only, context-first, goroutine-safe (the token store uses single-flight refresh). ## Install ```bash theme={"dark"} go get github.com/knoxcall/sdk-go@v1.0.0 ``` The package is `knoxcall`, imported as `github.com/knoxcall/sdk-go/knoxcall`. Module: [`github.com/knoxcall/sdk-go` on pkg.go.dev](https://pkg.go.dev/github.com/knoxcall/sdk-go). Source: [github.com/KnoxCall/sdk-go](https://github.com/KnoxCall/sdk-go). ## Create a client ```go theme={"dark"} import "github.com/knoxcall/sdk-go/knoxcall" // Zero-option — credentials from `knoxcall login` or the environment // (KNOXCALL_CLIENT_ID, KNOXCALL_CLIENT_SECRET): client, err := knoxcall.New(knoxcall.Options{}) // Or OAuth client credentials inline — tenant auto-discovered from the credential. client, err = knoxcall.New(knoxcall.Options{ClientID: "tk_xxxxxxxx", ClientSecret: "..."}) ``` Create the OAuth client in the dashboard under **Settings → API → OAuth Clients** — the steps are in [Create an OAuth client](/api-reference/authentication#create-an-oauth-client). Set `Sandbox: true` to target the isolated Test data plane (`sandbox.knoxcall.com` management host, `sandbox-{tenant}.knoxcall.com` proxy host) with a client created in Test mode. Passing conflicting credential options (e.g. `APIKey` and `ClientID`) is a construction error; explicit options always beat the environment. With no explicit credentials, the SDK resolves in priority order: a pre-acquired token env var (`KNOXCALL_ACCESS_TOKEN` / `KNOXCALL_API_KEY`), the credentials file written by `knoxcall login` (`~/.knoxcall/credentials.json`), then `KNOXCALL_CLIENT_ID` + `KNOXCALL_CLIENT_SECRET`. **Legacy API keys** — a pre-acquired key or token still works everywhere: `knoxcall.New(knoxcall.Options{APIKey: os.Getenv("KNOXCALL_API_KEY")})` accepts a minted `kc_…` token or a legacy `tk_…` / `AKE…` key. It is sent as-is and never auto-renewed — prefer `knoxcall login` or client credentials for new integrations. ### Log in once with the CLI On a developer machine, run [`knoxcall login`](/sdks/overview#log-in-once-with-the-cli) once (this module ships it: `go install github.com/knoxcall/sdk-go/cmd/knoxcall@latest` — every KnoxCall SDK ships the same command). It signs you in via your browser and stores the credential in `~/.knoxcall/credentials.json` — Go programs using this SDK pick the file up automatically, including the stored tenant and base URL, and handle refresh (cross-process safe, atomic rotation of the single-use refresh token). Select a profile with `KNOXCALL_PROFILE` (or `Credentials: knoxcall.StoredCredentials{Profile: "staging"}`). ```go theme={"dark"} client, err := knoxcall.New(knoxcall.Options{}) // zero config — tenant and base URL come from the login ``` ## Manage resources Create a route, then list with pagination — single-object methods return the unwrapped object; paginated lists return a typed `Page[T]`; `ListAll` walks every page: ```go theme={"dark"} ctx := context.Background() route, err := client.Routes.Create(ctx, knoxcall.CreateRouteInput{Name: "orders", TargetBaseURL: "https://api.example.com"}) page, err := client.Routes.List(ctx, &knoxcall.ListParams{Page: 2, PerPage: 50}) fmt.Println(page.Meta.Total, page.Meta.TotalPages, page.Meta.RequestID) // Walk every page in one call: all, err := client.Secrets.ListAll(ctx, nil) ``` The same pattern covers every resource: `Secrets`, `Webhooks`, `Clients`, `OAuthClients`, `Environments`, `APIKeys`, `Account`, `AuditLogs`, `Agents`, `Crypto`, `PKI`, `Vaults`, and `DynamicDB`. All methods take a `context.Context` first. ## Call routes through the proxy `client.Call()` proxies a request through a KnoxCall route to your upstream and returns the raw `*http.Response` — the upstream's status belongs to you; the SDK never turns it into an error. Reference routes by **slug** (write-once, rename-proof); UUIDs also work. ```go theme={"dark"} // GET resp, err := client.Call(ctx, "api-orders", &knoxcall.CallOptions{Path: "/users"}) defer resp.Body.Close() // POST with a body, targeting a specific environment resp, err = client.Call(ctx, "api-orders", &knoxcall.CallOptions{ Method: "POST", Path: "/v1/charges", Body: map[string]any{"amount": 2000, "currency": "usd"}, Environment: "staging", }) ``` `path` is the **upstream** path. On a KnoxCall cloud tenant host the data plane is served under `/api` (`https://{tenant}.knoxcall.com/api/`); the SDK adds that prefix itself whenever the proxy base is a cloud tenant host with no path of its own, and uses any other base verbatim (self-hosted, or an override that already carries a path). So `/api/v2/tickets` reaches an upstream path that itself begins with `/api`. For a per-call timeout, pass a context with a deadline (`context.WithTimeout`). ### Bound routes State the route (and optional defaults) once with `client.Route()`, then use plain HTTP verbs: ```go theme={"dark"} printnode := client.Route("api-printnode", &knoxcall.BoundRouteOptions{Environment: "production"}) resp, err := printnode.Get(ctx, "/computers", nil) resp, err = printnode.Post(ctx, "/printjobs", &knoxcall.CallOptions{Body: job}) resp, err = printnode.Request(ctx, "DELETE", "/printjobs/42", nil) // per-call options still override the bound defaults: resp, err = printnode.Get(ctx, "/computers", &knoxcall.CallOptions{Environment: "staging"}) ``` The handle holds no state beyond the defaults — retries, token refresh, and 401 re-mint behave exactly as on `Call()`. ## Route-aware interception (preview) Send an untouched third-party SDK's traffic through the Route that covers it — and through the ephemeral proxy where no Route does — with no per-SDK wiring. Turn the Route's **Intercept** toggle on and it takes effect on the next poll (60 s) or the next refusal, with no code change. The full decision order, what is and is not reached, and the hooks are on [Route-aware interception](/essentials/routes/route-aware-interception). ```go theme={"dark"} stop, err := client.Wrap.Intercept(ctx, knoxcall.WithHosts("api.resend.com")) // listed hosts with NO Route go through the ephemeral proxy if err != nil { log.Fatal(err) } defer stop.Stop() <-stop.Ready() // first manifest loaded // Any code — or SDK — on http.DefaultClient, untouched: res, err := http.DefaultClient.Get("https://api.hubapi.com/crm/v3/objects/contacts") // via the Route that covers api.hubapi.com ``` `Intercept` swaps `http.DefaultTransport` for a route-aware `RoundTripper` wrapping the previous one; `Stop()` restores it. Reached: `http.DefaultClient` and any `*http.Client` with a nil `Transport` (most SDK defaults). Not reached: a client built with its own `Transport` — hand those `client.Wrap.RoundTripper(knoxcall.WithRoutes())`, which makes the same decisions on an explicit transport (`Ready()`, `Refresh(ctx)`, `Manifest()`, `Stop()`). Per-host options: `knoxcall.WithHost("api.resend.com", knoxcall.HostEscrow("resend-key"))` (escrow) or `knoxcall.HostUnavailableDirect()` (transit only); `knoxcall.WithRequireContext()` limits interception to requests whose context descends from `client.Wrap.Routed(ctx)`. `KNOXCALL_INTERCEPT=off` is the kill switch. ## Verify webhooks `ConstructWebhookEvent` (also available as `client.Webhooks.ConstructEvent`) verifies the delivery's HMAC-SHA256 signature and parses it into a typed event in one step. Pass the raw body bytes — never re-serialized JSON: ```go theme={"dark"} func handler(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) event, err := knoxcall.ConstructWebhookEvent(body, r.Header, endpointSecret, nil) if err != nil { var ve *knoxcall.WebhookSignatureVerificationError if errors.As(err, &ve) { http.Error(w, "bad signature", http.StatusBadRequest) return } } switch event.Event { case "audit.event": audit, _ := event.AuditData() log.Println("audit:", audit.Action) default: // request.* data, _ := event.RequestData() log.Println(data.RouteName, data.Response.Status) } } ``` Formats beyond the default `legacy` header (`stripe`, `github`, `slack`, `aws-sns`, `custom`) are selected with `&knoxcall.ConstructEventOptions{Format: "stripe"}`; the replay window defaults to 300s; `custom` requires `HeaderName`. ## Handle errors All API failures are typed and unwrap to `*APIError` (status, machine-readable type, human message, `RequestID` for support): ```go theme={"dark"} _, err := client.Routes.Create(ctx, input) var rl *knoxcall.RateLimitError var ve *knoxcall.ValidationError switch { case errors.As(err, &rl): time.Sleep(time.Duration(rl.RetryAfter) * time.Second) case errors.As(err, &ve): log.Println("validation failed:", ve.Fields) } ``` Hierarchy: `AuthenticationError` (401), `PermissionDeniedError` (403), `NotFoundError` (404), `ConflictError` (409), `ValidationError` (422), `RateLimitError` (429), `ServerError` (5xx), plus `SignupError`, `WebhookSignatureVerificationError`, and `ConnectionError` / `ConnectionTimeoutError` for transport failures. ## Retries and idempotency Management requests retry automatically on transport errors and HTTP 408/429/500/502/503/504 (never 409), with exponential half-jitter backoff and `Retry-After` honored up to 30s. Every mutating request carries a ULID `X-Idempotency-Key` that stays stable across retries. A 401 purges the cached token and retries once with fresh credentials. Tune with `Options{RetryMaxAttempts, RetryBaseDelay, RetryMaxDelay}`. ## DPoP For higher-security tenants, enable sender-constrained tokens (RFC 9449) — the SDK generates an ES256 keypair, binds the access token via `cnf.jkt`, and signs a fresh proof per request: ```go theme={"dark"} client, err := knoxcall.New(knoxcall.Options{Tenant: "acme", DPoP: "always"}) ``` In the default `"auto"` mode the SDK starts with plain Bearer tokens and upgrades to DPoP automatically when the OAuth client record requires it (`require_dpop`): the token endpoint answers `invalid_dpop_proof`, the SDK generates a keypair, retries once, and operates as DPoP from then on. `"never"` opts out — if the server issues a DPoP-bound token anyway, the SDK returns a clear error rather than 401-looping. ## Full reference The package README documents every resource method, the ephemeral proxy, field-actions, crypto/PKI/vault operations, and credential-less `Signup()`: [github.com/KnoxCall/sdk-go](https://github.com/KnoxCall/sdk-go#readme). # Node.js SDK Source: https://docs.knoxcall.com/sdks/node @knoxcall/sdk — the official KnoxCall client for Node.js and TypeScript. OAuth 2.1 + DPoP under the hood; your code just calls methods. `@knoxcall/sdk` is the official KnoxCall client for Node.js and TypeScript. Every method is fully typed against the live `/v1` response shapes. ## Install ```bash theme={"dark"} npm install @knoxcall/sdk@^1.0.0 ``` Requires Node 18 or newer. Package: [`@knoxcall/sdk` on npm](https://www.npmjs.com/package/@knoxcall/sdk). Source: [github.com/KnoxCall/sdk-node](https://github.com/KnoxCall/sdk-node). ## Create a client ```typescript theme={"dark"} import { KnoxCall } from "@knoxcall/sdk"; // Zero-arg — credentials from `knoxcall login` or the environment // (KNOXCALL_CLIENT_ID, KNOXCALL_CLIENT_SECRET) const client = new KnoxCall(); // Or OAuth client credentials inline — no extra imports; tenant auto-discovered const client2 = new KnoxCall({ clientId: "tk_xxxxxxxx", clientSecret: "..." }); // Test mode: sandbox host + an OAuth client created in Test mode const sandbox = new KnoxCall({ clientId: "tk_xxxxxxxx", clientSecret: "...", sandbox: true }); ``` Create the OAuth client in the dashboard under **Settings → API → OAuth Clients** — the steps are in [Create an OAuth client](/api-reference/authentication#create-an-oauth-client). With no explicit credentials, the SDK auto-detects in priority order: a pre-acquired token env var (`KNOXCALL_ACCESS_TOKEN` / `KNOXCALL_API_KEY`), the credentials file written by `knoxcall login` (`~/.knoxcall/credentials.json`), workload identity (GitHub Actions, GCP, AWS, Azure, Vercel — the platform's OIDC token is exchanged for a KnoxCall access token, no stored secrets in CI), then `KNOXCALL_CLIENT_ID` + `KNOXCALL_CLIENT_SECRET`. **Legacy API keys** — a pre-acquired key or token still works everywhere: `new KnoxCall({ apiKey: process.env.KNOXCALL_API_KEY })` accepts a minted `kc_…` token or a legacy `tk_…` / `AKE…` key. It is sent as-is and never auto-renewed — prefer `knoxcall login` or client credentials for new integrations. ### Log in once with the CLI On a developer machine, run [`knoxcall login`](/sdks/overview#log-in-once-with-the-cli) once (this package ships it: `npm install -g @knoxcall/sdk`, or ad hoc with `npx -p @knoxcall/sdk knoxcall login` — every KnoxCall SDK ships the same command). It signs you in via your browser and stores the credential in `~/.knoxcall/credentials.json` — this SDK picks the file up automatically, including the stored tenant and base URL, and handles refresh (cross-process safe, atomic rotation of the single-use refresh token). If the stored credential is revoked or expired, the SDK throws `AuthenticationError` telling you to run `knoxcall login` again. Select a profile with `KNOXCALL_PROFILE`. ```typescript theme={"dark"} const client = new KnoxCall(); // zero config — tenant and base URL come from the login ``` ## Manage resources Create a route, then list with pagination — the server's `{data, meta}` envelope is unwrapped for single objects, and `iterate()` walks every page: ```typescript theme={"dark"} const route = await client.routes.create({ name: "Orders API", slug: "orders-api", target_base_url: "https://api.example.com", }); const page = await client.routes.list({ page: 2, per_page: 50 }); console.log(page.meta.total_pages); for await (const route of client.routes.iterate()) { console.log(route.slug ?? route.id); } ``` The same pattern covers every resource: `secrets`, `webhooks`, `clients`, `oauthClients`, `environments`, `apiKeys`, `account`, `auditLogs`, `agents`, `crypto`, `pki`, `vaults`, and `dynamicDb`. ## Call routes through the proxy `client.call()` proxies a request through a KnoxCall route to your upstream and returns the raw `Response` — the upstream's HTTP status belongs to you; the SDK never turns it into an error. Reference routes by **slug** (write-once, rename-proof); UUIDs also work. ```typescript theme={"dark"} // GET const resp = await client.call("orders-api", { path: "/users" }); const users = await resp.json(); // POST with body, targeting a specific environment await client.call("orders-api", { method: "POST", path: "/v1/charges", body: { amount: 2000, currency: "usd" }, environment: "staging", }); ``` `path` is the **upstream** path. On a KnoxCall cloud tenant host the data plane is served under `/api` (`https://{tenant}.knoxcall.com/api/`); the SDK adds that prefix itself whenever the proxy base is a cloud tenant host with no path of its own, and uses any other base verbatim (self-hosted, or an override that already carries a path). So `/api/v2/tickets` reaches an upstream path that itself begins with `/api`. ### Bound routes State the route (and optional defaults) once with `client.route()`, then use plain HTTP verbs: ```typescript theme={"dark"} const printnode = client.route("printnode", { environment: "production" }); const computers = await (await printnode.get("/computers")).json(); await printnode.post("/printjobs", { body: { printerId: 1, title: "Invoice" } }); await printnode.request("DELETE", "/printjobs/42"); // per-call options still override the bound defaults: await printnode.get("/computers", { environment: "staging" }); ``` The handle holds no state beyond the defaults — retries, token refresh, and 401 re-mint behave exactly as on `call()`. ## Route-aware interception (preview) Send an untouched third-party SDK's traffic through the Route that covers it — and through the ephemeral proxy where no Route does — with no per-SDK wiring. Turn the Route's **Intercept** toggle on and it takes effect on the next poll (60 s) or the next refusal, with no code change. The full decision order, what is and is not reached, and the hooks are on [Route-aware interception](/essentials/routes/route-aware-interception). ```typescript theme={"dark"} const stop = client.wrap.intercept({ hosts: ["api.resend.com"] }); // listed hosts with NO Route go through the ephemeral proxy await stop.ready; // first manifest loaded const hubspot = new Client({ accessToken: "placeholder" }); // an untouched SDK await hubspot.crm.contacts.basicApi.getPage(); // via the Route that covers api.hubapi.com — the Route injects the key stop.uninstall(); ``` Patched: the process's global `fetch` and `node:http` / `node:https` — which reaches axios, node-fetch, got, cross-fetch and most SDKs with no added dependency. Not reached: a `fetch` or `request` reference captured before install, or an SDK on its own undici `Dispatcher` — use `client.wrap.fetch({ routes: "auto" })` for those, which makes the same decisions on a `fetch` you inject (`ready`, `refresh()`, `manifest()`, `stop()`). Per-host options: `hosts: { "api.resend.com": { credential: { secret: "resend-key" } } }` (escrow) or `{ unavailable: "direct" }` (transit only). `KNOXCALL_INTERCEPT=off` is the kill switch. ## Verify webhooks `constructWebhookEvent` verifies the delivery signature AND parses it into a typed event in one step. Pass the **raw body bytes** (never re-serialized JSON): ```typescript theme={"dark"} import { constructWebhookEvent, WebhookSignatureVerificationError } from "@knoxcall/sdk"; app.post("/webhooks/knoxcall", express.raw({ type: "application/json" }), (req, res) => { try { const event = constructWebhookEvent( req.body, // Buffer — the raw bytes req.headers, process.env.KNOXCALL_WEBHOOK_SECRET!, // { format: "stripe" | "github" | "slack" | "aws-sns" | "custom" } — match your webhook's hmac_format (default "legacy") ); if (event.event === "request.server_error") { console.error(`${event.data.route_name} returned ${event.data.response.status}`); } res.sendStatus(200); } catch (e) { if (e instanceof WebhookSignatureVerificationError) return res.sendStatus(400); throw e; } }); ``` Also available as `client.webhooks.constructEvent()`. Same positional signature as every other KnoxCall SDK — `(rawBody, headers, secret, options?)`, pure synchronous computation. Timestamps are replay-checked (default tolerance 300s; pass `{ toleranceSeconds: null }` to disable). ## Handle errors All errors extend `KnoxCallError` and carry `.status`, `.code`, `.headers`, `.body`, and `.requestId`: ```typescript theme={"dark"} import { RateLimitError, ValidationError, AuthenticationError } from "@knoxcall/sdk"; try { await client.routes.create({ name: "x", target_base_url: "https://y" }); } catch (e) { if (e instanceof RateLimitError) { await sleep((e.retryAfter ?? 1) * 1000); // retry } else if (e instanceof ValidationError) { console.error("Validation failed:", e.fields, e.requestId); } else if (e instanceof AuthenticationError) { console.error("Auth failed:", e.message); } else { throw e; } } ``` Hierarchy: `APIConnectionError` / `APIConnectionTimeoutError` / `APIUserAbortError` (transport), `AuthenticationError` (401), `PermissionDeniedError` (403), `NotFoundError` (404), `ConflictError` (409), `ValidationError` (422, with `.fields`), `RateLimitError` (429, with `.retryAfter`), `ServerError` (5xx), plus `WebhookSignatureVerificationError`, `SignupError`, and `BootstrapError`. ## Retries and idempotency Management requests retry automatically on network errors and HTTP 408/429/500/502/503/504 with exponential jittered backoff (never 409; `Retry-After` honored on 429, capped at 30s). Mutating requests carry a ULID `X-Idempotency-Key` that stays stable across retries — pass `{ idempotencyKey }` on any mutating method to supply your own. Tune with: ```typescript theme={"dark"} new KnoxCall({ tenant: "acme", retry: { maxAttempts: 5, baseDelayMs: 200, maxDelayMs: 10000 }, }); ``` ## DPoP For higher-security tenants, enable sender-constrained tokens (RFC 9449) — the SDK generates an ES256 keypair, binds the access token via `cnf.jkt`, and signs a fresh proof per request: ```typescript theme={"dark"} const client = new KnoxCall({ tenant: "acme", dpop: "always" }); ``` In the default `"auto"` mode the SDK upgrades to DPoP automatically when the OAuth client record requires it. ## Full reference The package README documents every resource method, token stores (`FileTokenStore`, `RedisTokenStore`), telemetry hooks, the ephemeral proxy, the low-level `Session`, and credential-less `signup()`: [github.com/KnoxCall/sdk-node](https://github.com/KnoxCall/sdk-node#readme). # SDKs Overview Source: https://docs.knoxcall.com/sdks/overview Official KnoxCall SDKs for Node.js, Python, Go, PHP, and Ruby — plus a Terraform provider and browser Elements — all held to one behavioral standard. KnoxCall ships first-party SDKs for five server languages, a Terraform provider, and browser Elements packages. The five core SDKs implement the same behavioral standard, adapted to each language's idiom — switching languages (or running a polyglot stack) never changes semantics. ## Language support | Package | Runtime | Surface | | - | - | - | | [`@knoxcall/sdk`](/sdks/node) | Node.js ≥ 18, TypeScript-first | Full management + data plane | | [`knoxcall`](/sdks/python) | Python ≥ 3.10, sync **and** async | Full management + data plane | | [`github.com/knoxcall/sdk-go`](/sdks/go) | Go, stdlib only (zero dependencies) | Full management + data plane | | [`knoxcall/sdk`](/sdks/php) | PHP ≥ 8.1, `ext-curl` + `ext-json` only | Full management + data plane | | [`knoxcall`](/sdks/ruby) | Ruby ≥ 3.1, stdlib only | Full management + data plane | | [`terraform-provider-knoxcall`](/sdks/terraform) | Terraform (Plugin Framework) | Routes, secrets, clients, webhooks as code | | Language | Install | | - | - | | Node.js | `npm install @knoxcall/sdk@^1.0.0` | | Python | `pip install "knoxcall>=1.0,<2.0"` | | Go | `go get github.com/knoxcall/sdk-go@v1.0.0` | | PHP | `composer require knoxcall/sdk:^1.0` | | Ruby | `gem "knoxcall", "~> 1.0"` | ### Registries and source Each package's registry page, and the public repository its source is mirrored to. The last two rows are the browser Elements packages. | Package | Registry | Source | | - | - | - | | `@knoxcall/sdk` | [npm](https://www.npmjs.com/package/@knoxcall/sdk) | [KnoxCall/sdk-node](https://github.com/KnoxCall/sdk-node) | | `knoxcall` (Python) | [PyPI](https://pypi.org/project/knoxcall/) | [KnoxCall/sdk-python](https://github.com/KnoxCall/sdk-python) | | `github.com/knoxcall/sdk-go` | [pkg.go.dev](https://pkg.go.dev/github.com/knoxcall/sdk-go) | [KnoxCall/sdk-go](https://github.com/KnoxCall/sdk-go) | | `knoxcall/sdk` | [Packagist](https://packagist.org/packages/knoxcall/sdk) | [KnoxCall/sdk-php](https://github.com/KnoxCall/sdk-php) | | `knoxcall` (Ruby) | [RubyGems](https://rubygems.org/gems/knoxcall) | [KnoxCall/sdk-ruby](https://github.com/KnoxCall/sdk-ruby) | | `@knoxcall/browser` | [npm](https://www.npmjs.com/package/@knoxcall/browser) | [KnoxCall/sdk-browser](https://github.com/KnoxCall/sdk-browser) | | `@knoxcall/react` | [npm](https://www.npmjs.com/package/@knoxcall/react) | [KnoxCall/sdk-browser](https://github.com/KnoxCall/sdk-browser) | ## What every SDK guarantees The core SDKs are held to a single written parity spec, with test coverage required for each behavior: * **Automatic retries** — HTTP 408, 429, 500, 502, 503, 504 retried with exponential half-jitter backoff; `Retry-After` honored on 429 (capped at 30s); 409 is never retried (a real conflict does not resolve by replaying). * **Idempotency by default** — every mutating request carries a ULID `X-Idempotency-Key` generated once per logical request and stable across retries, so replays are safe. * **Transparent 401 re-mint** — a 401 purges the cached token and retries once with fresh credentials before surfacing an error. Long-lived processes never wedge on a revoked or rotated token. * **Token lifecycle** — tokens cached per tenant + scope with single-flight refresh, refresh-ahead, and a stale-but-valid fallback when the token endpoint is briefly unreachable. * **DPoP sender-constrained tokens** (RFC 9449) — every SDK auto-upgrades to DPoP when the OAuth client requires it (`dpop: "auto"`, the default) and supports `"always"`: the SDK generates an ES256 keypair, binds the access token via `cnf.jkt`, and signs a fresh proof per request. * **Shared CLI login** — every SDK auto-detects the credentials file written by `knoxcall login` (`~/.knoxcall/credentials.json`), with cross-process-safe rotation of the single-use refresh token. Sign in once; every language picks it up. * **Secret redaction** — client secrets, access tokens, and OIDC subject tokens never appear in debug output, logs, or error messages. * **Typed errors with request IDs** — 401/403/404/409/422/429/5xx each map to a named error type carrying the server's `request_id` (quote it when contacting support). * **Envelope + pagination** — the server wraps JSON responses in `{data, meta}`. Single-object methods return `data` unwrapped; paginated lists take `page` / `per_page` (default 20, cap 100) and return `{data: [...], meta: {total, page, per_page, total_pages, request_id}}`, with an iterator that walks every page for you. * **Raw data plane** — proxy calls (`call()` / bound routes / `ephemeral()`) return the raw HTTP response and never raise on the upstream's status; legacy `tk_…` / `AKE…` keys automatically travel as `x-knoxcall-key`. Mutating data-plane requests are never replayed after they may have reached the wire. * **Webhook `constructEvent`** — verify the delivery's HMAC-SHA256 signature and parse it into a typed event in one step, across all six signature formats (`legacy`, `stripe`, `github`, `slack`, `aws-sns`, `custom`), with constant-time comparison and replay protection. * **Sandbox mode** — a `sandbox` constructor option targets the isolated Test environment (`sandbox.knoxcall.com` management host, `sandbox-{tenant}.knoxcall.com` proxy host) with a `tk_test_` key. * **Credential-less `signup()`** — create an account headlessly with no constructed client (see [AI agent onboarding](/getting-started/ai-agent-onboarding)). ## Authentication quickstart Every SDK resolves credentials the same way: explicit constructor options beat environment variables, and conflicting options fail at construction. On a developer machine, [log in once with the CLI](#log-in-once-with-the-cli) below and skip configuration entirely. For servers and CI, [create an OAuth client](/api-reference/authentication#create-an-oauth-client) (dashboard → **Settings → API → OAuth Clients**), set the environment once, and construct with zero arguments: ```bash theme={"dark"} # OAuth client-credentials grant (recommended for servers) export KNOXCALL_CLIENT_ID="tk_xxxxxxxx" # the Client ID export KNOXCALL_CLIENT_SECRET="..." # the Client secret ``` A client created in Test mode calls the Test environment: also pass the constructor's `sandbox` option. ```typescript Node.js theme={"dark"} import { KnoxCall } from "@knoxcall/sdk"; const client = new KnoxCall(); // credentials from `knoxcall login` or the environment ``` ```python Python theme={"dark"} from knoxcall import KnoxCall client = KnoxCall() # credentials from `knoxcall login` or the environment ``` ```go Go theme={"dark"} import "github.com/knoxcall/sdk-go/knoxcall" client, err := knoxcall.New(knoxcall.Options{}) // credentials from `knoxcall login` or the environment ``` ```php PHP theme={"dark"} use KnoxCall\KnoxCall; $client = new KnoxCall(); // credentials from `knoxcall login` or the environment ``` ```ruby Ruby theme={"dark"} require "knoxcall" client = KnoxCall::Client.new # credentials from `knoxcall login` or the environment ``` You do not need to configure a tenant: management calls resolve it server-side from the credential, and the data-plane hostname is auto-discovered on first proxy call. Passing `tenant` (or setting `KNOXCALL_TENANT`) just skips the discovery lookup. **Legacy API keys** still work everywhere: set `KNOXCALL_API_KEY` to a long-lived key (`tk_live_…`, `tk_test_…`, `AKE…`) or a pre-acquired `kc_…` token instead of the client-credentials pair — or pass it to the constructor as `apiKey` / `api_key`. A pre-acquired key is sent as-is and never auto-renewed. Prefer `knoxcall login` or an OAuth client for new integrations — see [Authentication](/api-reference/authentication). ### Log in once with the CLI On a developer machine, skip credentials entirely. Every KnoxCall SDK package ships the `knoxcall` command via its own package manager (pip, npm, gem, Composer, `go install`) — same surface, same credentials file, so it doesn't matter which one you install. Sign in once in your browser (authorization code + PKCE) and every script, notebook, and agent on the machine — in **any** of the five languages — picks the credential up automatically. No env vars, no keys in code: ```bash theme={"dark"} pip install "knoxcall>=1.0,<2.0" # or: npm i -g @knoxcall/sdk@^1.0.0 / gem install knoxcall -v "~> 1.0" / # composer global require knoxcall/sdk:^1.0 / # go install github.com/knoxcall/sdk-go/cmd/knoxcall@v1.0.0 knoxcall login # opens your browser (PKCE); prints the URL too knoxcall login --device # headless/SSH machines: device-code flow (RFC 8628) knoxcall login --profile staging # keep multiple accounts side by side knoxcall whoami # show the signed-in tenant knoxcall logout # revoke + remove the stored credential ``` Your browser opens the KnoxCall dashboard. Sign in if you are not already, check what is being authorized, choose the workspace and click **Approve** — the terminal finishes on its own. With `--device`, open the link the terminal prints on any device, confirm the code it shows, and approve there. Approving needs an account with a passkey, security key or authenticator app set up. `knoxcall login` signs you in to **Live**. To call the Test environment, use an [OAuth client](/api-reference/authentication#create-an-oauth-client) created in Test mode and the SDK's `sandbox` option. After that, the zero-argument constructors above just work: credentials land in `~/.knoxcall/credentials.json` (file mode 0600), and the stored tenant and base URL seed the client automatically — explicit constructor options and env vars always win. Access tokens refresh themselves, and refreshes are cross-process safe (file lock + atomic rotation of the single-use refresh token). If a refresh fails because the credential was revoked or expired, the SDK raises its typed authentication error telling you to run `knoxcall login` again. Override the file path with `KNOXCALL_CREDENTIALS_FILE`; select a non-default profile with `KNOXCALL_PROFILE`. ### Credential resolution order With no explicit constructor credentials, every SDK resolves from the environment in priority order: 1. `KNOXCALL_ACCESS_TOKEN` (or `KNOXCALL_API_KEY`) — a pre-acquired token 2. The credentials file written by `knoxcall login` (`~/.knoxcall/credentials.json`) 3. Workload identity auto-detect — Node.js and Python only (GitHub Actions, GCP, AWS IRSA, Azure Managed Identity, Vercel, and other CI OIDC issuers); zero stored secrets in CI 4. `KNOXCALL_CLIENT_ID` + `KNOXCALL_CLIENT_SECRET` — client-credentials grant ### Canonical environment variables | Variable | Meaning | | - | - | | `KNOXCALL_CLIENT_ID` / `KNOXCALL_CLIENT_SECRET` | OAuth client-credentials grant | | `KNOXCALL_API_KEY` | pre-acquired key or token (`KNOXCALL_ACCESS_TOKEN` is an equivalent spelling and wins when both are set) | | `KNOXCALL_TENANT` | tenant slug (optional — auto-discovered when unset) | | `KNOXCALL_ENVIRONMENT` | default environment for data-plane calls | | `KNOXCALL_BASE_URL` | management API base override | | `KNOXCALL_PROXY_BASE_URL` | data-plane base override (a cloud tenant host still gets the `/api` entry point added unless the base already carries a path) | | `KNOXCALL_CREDENTIALS_FILE` | credentials-file path override (default `~/.knoxcall/credentials.json`) | | `KNOXCALL_PROFILE` | credentials-file profile to use (default `default`) | ## Pick your language `@knoxcall/sdk` — fully typed, DPoP, workload identity, token stores `knoxcall` — one import, sync or async, thread- and fork-safe `github.com/knoxcall/sdk-go` — stdlib only, context-first, goroutine-safe `knoxcall/sdk` — PHP-FPM aware token handling `knoxcall` gem — Mutex-safe for Puma and Sidekiq Routes, secrets, clients, and webhooks as code # PHP SDK Source: https://docs.knoxcall.com/sdks/php knoxcall/sdk — the official KnoxCall client for PHP 8.1+. OAuth 2.1 under the hood; ext-curl + ext-json only. `knoxcall/sdk` is the official KnoxCall client for PHP. Requires PHP >= 8.1 with `ext-curl` + `ext-json` — no other runtime dependencies. ## Install ```bash theme={"dark"} composer require knoxcall/sdk:^1.0 ``` Package: [`knoxcall/sdk` on Packagist](https://packagist.org/packages/knoxcall/sdk). Source: [github.com/KnoxCall/sdk-php](https://github.com/KnoxCall/sdk-php). ## Create a client ```php theme={"dark"} use KnoxCall\KnoxCall; // Zero-arg — credentials from `knoxcall login` or the environment // (KNOXCALL_CLIENT_ID, KNOXCALL_CLIENT_SECRET) $client = new KnoxCall(); // Or OAuth client credentials inline — no extra imports; tenant auto-discovered $client = new KnoxCall(['client_id' => 'tk_xxxxxxxx', 'client_secret' => '...']); ``` Create the OAuth client in the dashboard under **Settings → API → OAuth Clients** — the steps are in [Create an OAuth client](/api-reference/authentication#create-an-oauth-client). For the Stripe-style isolated Test environment, pass `'sandbox' => true` (uses `https://sandbox.knoxcall.com` + `https://sandbox-{tenant}.knoxcall.com`; requires a client created in Test mode). Conflicting explicit options (e.g. `api_key` + `client_id`) throw at construction. With no explicit credentials, the SDK resolves in priority order: a pre-acquired token env var (`KNOXCALL_ACCESS_TOKEN` / `KNOXCALL_API_KEY`), the credentials file written by `knoxcall login` (`~/.knoxcall/credentials.json`), then `KNOXCALL_CLIENT_ID` + `KNOXCALL_CLIENT_SECRET`. **Legacy API keys** — a pre-acquired key or token still works everywhere: `new KnoxCall(['api_key' => getenv('KNOXCALL_API_KEY')])` accepts a minted `kc_…` token or a legacy `tk_…` / `AKE…` key. It is sent as-is and never auto-renewed — prefer `knoxcall login` or client credentials for new integrations. **Token caching under PHP-FPM:** typical deployments are per-request processes, so under `client_credentials` each request mints one token. If that matters, mint out of band, cache it yourself (APCu/Redis), and construct with `['access_token' => $cached]`. Long-running workers (CLI, queues, Octane) get in-process caching with refresh-ahead automatically. ### Log in once with the CLI On a developer machine, run [`knoxcall login`](/sdks/overview#log-in-once-with-the-cli) once (this package ships it as a Composer bin: `composer global require knoxcall/sdk` or `vendor/bin/knoxcall` — every KnoxCall SDK ships the same command). It signs you in via your browser and stores the credential in `~/.knoxcall/credentials.json` — with no explicit credentials, `new KnoxCall()` picks the file up automatically, including the stored tenant and base URL. Refreshes are cross-process safe (file lock + atomic rotation of the single-use refresh token); if the credential was revoked or expired you get an `AuthenticationException` telling you to run `knoxcall login` again. Select a profile with `KNOXCALL_PROFILE`. Under PHP-FPM the file's fresh-token fast path is the effective cross-request cache: a request re-reads the file (a local read, no HTTP) while the stored token is fresh and only performs a real refresh-token round trip when it is not — no APCu/Redis store needed for this credential type. ## Manage resources Create a route, then list with pagination — single-object methods return `data` unwrapped; paginated `list()` methods return the `{data, meta}` envelope; `iterate()` walks all pages: ```php theme={"dark"} $route = $client->routes->create(['name' => 'stripe', 'target_base_url' => 'https://api.stripe.com']); $page = $client->routes->list(['page' => 2, 'per_page' => 50]); // $page['data'] = rows; $page['meta'] = ['total', 'page', 'per_page', 'total_pages', 'request_id'] foreach ($client->routes->iterate() as $route) { // every page, transparently echo $route['slug'], "\n"; } ``` The same pattern covers every resource: `secrets`, `webhooks`, `clients`, `oauthClients`, `environments`, `apiKeys`, `account`, `auditLogs`, `agents`, `crypto`, `pki`, `vaults`, and `dynamicDb`. ## Call routes through the proxy `call()` proxies a request through a KnoxCall route to your upstream and returns the raw response (`['status', 'headers', 'body']`) — upstream HTTP errors are never thrown; they belong to you. Reference routes by **slug** (write-once, rename-proof); UUIDs also work. ```php theme={"dark"} $resp = $client->call('billing-stripe', [ 'method' => 'POST', 'path' => '/v1/charges', 'body' => ['amount' => 2000, 'currency' => 'usd'], 'environment' => 'staging', ]); $charge = json_decode($resp['body'], true); ``` `path` is the **upstream** path. On a KnoxCall cloud tenant host the data plane is served under `/api` (`https://{tenant}.knoxcall.com/api/`); the SDK adds that prefix itself whenever the proxy base is a cloud tenant host with no path of its own, and uses any other base verbatim (self-hosted, or an override that already carries a path). So `/api/v2/tickets` reaches an upstream path that itself begins with `/api`. Legacy (non-`kc_`) keys automatically travel as the `x-knoxcall-key` header instead of `Authorization: Bearer`. ### Bound routes State the route (and optional defaults) once, then use plain HTTP verbs: ```php theme={"dark"} $printnode = $client->route('printnode-api', ['environment' => 'production']); $computers = json_decode($printnode->get('/computers')['body'], true); $printnode->post('/printjobs', ['body' => ['printerId' => 1, 'title' => 'Invoice']]); $printnode->request('DELETE', '/printjobs/42'); // per-call options still override the bound defaults: $printnode->get('/computers', ['environment' => 'staging']); ``` ## Route-aware interception (preview) Send an untouched third-party SDK's traffic through the Route that covers it — and through the ephemeral proxy where no Route does. PHP has no process-wide HTTP seam (curl has no hook), so the SDK offers the two seams most PHP SDKs accept: a route-aware PSR-18 client, and a Guzzle middleware for a handler stack the SDK lets you push onto. Turn the Route's **Intercept** toggle on and it takes effect on the next request after the 60 s TTL, or on the next refusal, with no code change. The full decision order and the hooks are on [Route-aware interception](/essentials/routes/route-aware-interception). ```php theme={"dark"} $http = $client->wrap->httpClient(['routes' => 'auto']); // a PSR-18 client, route-aware $http->ready(); // first manifest loaded // …or an SDK that builds its own Guzzle stack and lets you push middleware: $stack = GuzzleHttp\HandlerStack::create(); $stack->push($client->wrap->guzzleMiddleware(['hosts' => ['api.resend.com']]), 'knoxcall'); $hubspot = HubSpot\Factory::createWithAccessToken('placeholder', new GuzzleHttp\Client(['handler' => $stack])); // every call to api.hubapi.com goes via the Route that covers it — the Route injects the key ``` An explicit transport treats every host as listed: a Route in the manifest covering host + path goes through that Route; everything else goes through the ephemeral proxy. Per-host options: `'hosts' => ['api.resend.com' => ['credential' => ['secret' => 'resend-key']]]` (escrow) or `['unavailable' => 'direct']` (transit only). PHP processes are usually per-request, so pass a PSR-16 cache (`'cache' => $apcu`) to share the manifest across them. An SDK that builds its own curl handle is not reached — point it at a gateway URL instead. `KNOXCALL_INTERCEPT=off` is the kill switch. ## Verify webhooks `constructEvent()` verifies the delivery AND parses it in one step — use it in your webhook endpoint with the RAW request body: ```php theme={"dark"} use KnoxCall\KnoxCall; use KnoxCall\WebhookSignatureVerificationException; $rawBody = file_get_contents('php://input'); try { $event = KnoxCall::constructEvent($rawBody, getallheaders(), $endpointSecret, [ 'format' => 'stripe', // must match the webhook's hmac_format (default 'legacy') 'tolerance_seconds' => 300, // replay window; null disables ]); } catch (WebhookSignatureVerificationException $e) { http_response_code(400); exit; } match ($event['event']) { 'request.server_error' => alert($event['data']['route_name'], $event['data']['response']['status']), 'audit.event' => log($event['data']['action']), default => null, // the event-type list is open — unknown types still parse }; ``` Supported formats mirror the server exactly: `legacy`, `stripe`, `github`, `slack`, `aws-sns`, and `custom` (pass `header_name`). All are HMAC-SHA256 with constant-time comparison. ## Handle errors ```php theme={"dark"} try { $client->routes->create(['name' => 'x', 'target_base_url' => 'y']); } catch (\KnoxCall\RateLimitException $e) { sleep($e->retryAfter ?? 1); } catch (\KnoxCall\ValidationException $e) { var_dump($e->fields); } ``` Hierarchy: `KnoxCallException` (base) → `ApiException` (with `->statusCode`, `->errorCode`, `->requestId`, `->responseHeaders`, `->responseBody`) → `AuthenticationException` (401), `PermissionDeniedException` (403), `NotFoundException` (404), `ConflictException` (409), `ValidationException` (422, with `->fields`), `RateLimitException` (429, with `->retryAfter`), `ServerException` (5xx), `SignupException`; plus `ConnectionException` / `ConnectionTimeoutException` (transport) and `WebhookSignatureVerificationException`. Every `ApiException` carries the server's `request_id` — include it when contacting support. ## Retries and idempotency Management requests retry HTTP 408/429/500/502/503/504 (never 409) with half-jitter exponential backoff, honoring `Retry-After` up to 30s; a 401 purges the cached token and re-auths once. Every mutating request carries a ULID `X-Idempotency-Key` that stays stable across retries. Data-plane calls never replay a mutation that may have reached the upstream. Configure with `retry_max_attempts`, `retry_base_delay_ms`, `retry_max_delay_ms`, `timeout_ms`. ## DPoP For higher-security tenants, enable sender-constrained tokens (RFC 9449) — the SDK generates an ES256 keypair, binds the access token via `cnf.jkt`, and signs a fresh proof per request: ```php theme={"dark"} $client = new KnoxCall(['tenant' => 'acme', 'dpop' => 'always']); ``` In the default `'auto'` mode the SDK starts with plain Bearer tokens and upgrades to DPoP automatically when the OAuth client record requires it (`require_dpop`): the token endpoint answers `invalid_dpop_proof`, the SDK generates a keypair, retries once, and operates as DPoP from then on. `'never'` opts out — if the server issues a DPoP-bound token anyway, the SDK throws a clear error rather than 401-looping. ## Full reference The package README documents every resource method, the ephemeral proxy, crypto/PKI/vault operations, and credential-less `KnoxCall::signup()`: [github.com/KnoxCall/sdk-php](https://github.com/KnoxCall/sdk-php#readme). # Python SDK Source: https://docs.knoxcall.com/sdks/python knoxcall — the official KnoxCall client for Python. One import, sync or async; OAuth 2.1 + DPoP under the hood. `knoxcall` is the official KnoxCall client for Python. One import gives you a sync client (thread- and fork-safe, share it as a module-level singleton) or an async client for FastAPI and friends. Every method's return shape is a `TypedDict` in `knoxcall.types`, so IDEs and mypy see the exact server fields. ## Install ```bash theme={"dark"} pip install "knoxcall>=1.0,<2.0" ``` Requires Python 3.10+. Package: [`knoxcall` on PyPI](https://pypi.org/project/knoxcall/). Source: [github.com/KnoxCall/sdk-python](https://github.com/KnoxCall/sdk-python). ## Create a client ```python theme={"dark"} from knoxcall import KnoxCall # Zero-arg — credentials from `knoxcall login` or the environment # (KNOXCALL_CLIENT_ID, KNOXCALL_CLIENT_SECRET) client = KnoxCall() # Or OAuth client credentials inline — the tenant is discovered from the credential client = KnoxCall(client_id="tk_xxxxxxxx", client_secret="...") # Test mode: sandbox host + an OAuth client created in Test mode sandbox = KnoxCall(client_id="tk_xxxxxxxx", client_secret="...", sandbox=True) # Async — use in FastAPI, async frameworks, etc. async with KnoxCall(sync=False) as client: routes = await client.routes.list() ``` Create the OAuth client in the dashboard under **Settings → API → OAuth Clients** — the steps are in [Create an OAuth client](/api-reference/authentication#create-an-oauth-client). With no explicit credentials, the SDK auto-detects in priority order: a pre-acquired token env var (`KNOXCALL_ACCESS_TOKEN` / `KNOXCALL_API_KEY`), the credentials file written by `knoxcall login` (`~/.knoxcall/credentials.json`), workload identity (GitHub Actions, GCP, AWS IRSA, Azure Managed Identity, Vercel, CircleCI — no stored secrets in CI), then `KNOXCALL_CLIENT_ID` + `KNOXCALL_CLIENT_SECRET`. **Legacy API keys** — a pre-acquired key or token still works everywhere: `KnoxCall(api_key="tk_live_...")` accepts a minted `kc_…` token or a legacy `tk_…` / `AKE…` key. It is sent as-is and never auto-renewed — prefer `knoxcall login` or client credentials for new integrations. ## Log in once with the CLI The package ships the `knoxcall` command. Sign in once in your browser and every script, notebook, and agent on the machine picks the credential up automatically — no env vars, no keys in code: ```bash theme={"dark"} knoxcall login # opens your browser (PKCE); prints the URL too knoxcall login --device # headless/SSH machines: device-code flow knoxcall login --profile staging # keep multiple accounts side by side knoxcall whoami # show the signed-in tenant knoxcall logout # revoke + remove the stored credential ``` ```python theme={"dark"} from knoxcall import KnoxCall with KnoxCall() as client: # zero config — tenant and base URL come from the login routes = client.routes.list() ``` Credentials are stored in `~/.knoxcall/credentials.json` (file mode 0600). The stored tenant and base URL seed the client automatically; explicit constructor arguments or env vars always win. Access tokens refresh themselves, and refreshes are cross-process safe (file lock + atomic rotation of the single-use refresh token). If a refresh fails because the credential was revoked or expired, the SDK raises `AuthenticationError` telling you to run `knoxcall login` again. Select a non-default profile with `KNOXCALL_PROFILE` (or `StoredCredentials(profile=...)`). The same file is picked up by all five SDKs — log in once, use any language. ## Manage resources Create a route, then list with pagination — single-object methods return `data` unwrapped; paginated lists return the full `{"data": [...], "meta": {...}}` page; `iterate()` walks every page: ```python theme={"dark"} new = await client.routes.create(name="my-api", target_base_url="https://api.example.com") page = await client.routes.list(page=2, per_page=50) print(page["meta"]["total_pages"], [r["name"] for r in page["data"]]) async for route in client.routes.iterate(): print(route["name"]) ``` The sync client exposes the identical surface without `await`. The same pattern covers every resource: `secrets`, `webhooks`, `clients`, `oauth_clients`, `environments`, `api_keys`, `account`, `audit_logs`, `agents`, `crypto`, `pki`, `vaults`, and `dynamic_db`. ## Call routes through the proxy `client.call()` proxies a request through a KnoxCall route to your upstream and returns the raw `httpx.Response` — the upstream's HTTP status belongs to you; the SDK never turns it into an error. Reference routes by **slug** (write-once, rename-proof); UUIDs also work. ```python theme={"dark"} # GET resp = await client.call("payments-stripe", path="/users") users = resp.json() # POST with body resp = await client.call("payments-stripe", method="POST", path="/v1/charges", body={"amount": 2000, "currency": "usd"}) # Target a specific environment resp = await client.call("payments-stripe", path="/data", environment="staging") ``` `path` is the **upstream** path. On a KnoxCall cloud tenant host the data plane is served under `/api` (`https://{tenant}.knoxcall.com/api/`); the SDK adds that prefix itself whenever the proxy base is a cloud tenant host with no path of its own, and uses any other base verbatim (self-hosted, or an override that already carries a path). So `/api/v2/tickets` reaches an upstream path that itself begins with `/api`. ### Bound routes State the route (and optional defaults) once with `client.route()`, then use plain HTTP verbs: ```python theme={"dark"} printnode = client.route("ops-printnode", environment="production") computers = printnode.get("/computers").json() printnode.post("/printjobs", body={"printerId": 1, "title": "Invoice"}) printnode.request("DELETE", "/printjobs/42") # per-call kwargs still override the bound defaults: printnode.get("/computers", environment="staging") ``` Works identically on the async client (`await printnode.get(...)`). The handle holds no state beyond the defaults — retries, token refresh, and 401 re-mint behave exactly as on `call()`. ## Route-aware interception (preview) Send an untouched third-party SDK's traffic through the Route that covers it — and through the ephemeral proxy where no Route does — with no per-SDK wiring. Turn the Route's **Intercept** toggle on and it takes effect on the next request after the 60 s TTL, or on the next refusal, with no code change. The full decision order, what is and is not reached, and the hooks are on [Route-aware interception](/essentials/routes/route-aware-interception). ```python theme={"dark"} knox = KnoxCall() # the sync client drives the process-wide patch stop = knox.wrap.intercept(hosts=["api.resend.com"]) # listed hosts with NO Route go through the ephemeral proxy stop.ready() # first manifest loaded hubspot = HubSpot(access_token="placeholder") # an untouched SDK (urllib3 underneath) hubspot.crm.contacts.basic_api.get_page() # via the Route that covers api.hubapi.com — the Route injects the key stop.uninstall() ``` Patched: `httpx` (sync and async clients on the default transport) and `urllib3` (`requests`, OpenAPI-generated clients, `botocore`). `aiohttp` is opt-in on the async client — `KnoxCallAsync(...).wrap.intercept(stacks=["aiohttp"])` patches `aiohttp.ClientSession._request` (aiohttp 3.13 or newer, installed separately) and answers with a real `aiohttp.ClientResponse`; a WebSocket handshake goes direct. Not reached: an explicitly injected custom transport, raw `http.client`, `pycurl` — use `knox.wrap.transport(routes="auto")` for a transport you inject, which makes the same decisions (`ready()`, `refresh()`, `manifest()`). `require_context=True` limits interception to code inside `with knox.wrap.routed():`. Per-host options: `hosts={"api.resend.com": {"credential": {"secret": "resend-key"}}}` (escrow) or `{"unavailable": "direct"}` (transit only). `KNOXCALL_INTERCEPT=off` is the kill switch. ## Verify webhooks `construct_webhook_event` verifies a delivery AND returns the parsed, typed event in one step. Pass the RAW request body (never re-serialized JSON): ```python theme={"dark"} from knoxcall import construct_webhook_event, WebhookSignatureVerificationError try: event = construct_webhook_event(request.body, request.headers, "whsec_...") except WebhookSignatureVerificationError: return 400 if event["event"] == "request.server_error": alert(event["data"]["route_name"], event["data"]["response"]["status"]) ``` `format=` matches the webhook's configured `hmac_format`: `"legacy"` (default), `"stripe"`, `"github"`, `"slack"`, `"aws-sns"`, or `"custom"` (pass `header_name=`). `tolerance_seconds` (default 300) bounds replay; pass `None` to disable. Also available as `client.webhooks.construct_event()` on both facades. ## Handle errors ```python theme={"dark"} from knoxcall import RateLimitError, ValidationError, AuthenticationError, NotFoundError try: await client.routes.create(name="x", target_base_url="https://api.example.com") except ValidationError as e: print(e.fields) except RateLimitError as e: await asyncio.sleep(e.retry_after or 1) except AuthenticationError: await client.authenticate() except NotFoundError: pass ``` 403 maps to `PermissionDeniedError`. Transport failures map to `APIConnectionError` / `APIConnectionTimeoutError`. Every API error carries the server's `request_id` — quote it when contacting support. ## Retries and idempotency Management requests retry automatically on 408/429/500/502/503/504 with exponential backoff + jitter (3 attempts by default; never 409; `Retry-After` honored on 429, capped at 30s). Mutating requests get an auto-generated ULID idempotency key so retries are safe. A 401 triggers one transparent token re-mint. Data-plane transport failures retry only when safe: connection-refused always, later failures (read timeout, keepalive reset) only for GET/HEAD — a mutating request is never replayed. ## DPoP ```python theme={"dark"} client = KnoxCall(tenant="acme", dpop="always") ``` The SDK generates an ES256 keypair, binds the access token via `cnf.jkt`, and signs a fresh proof on every request — both management calls and proxy `call()` requests. In the default `"auto"` mode it upgrades automatically when the OAuth client requires DPoP. ## Full reference The package README documents every resource method, the ephemeral proxy, Redis token store, request-body encoding (`datetime` / `Decimal` / `UUID` / `set` out of the box), thread-safety details, and credential-less `signup()` / `signup_sync()`: [github.com/KnoxCall/sdk-python](https://github.com/KnoxCall/sdk-python#readme). # Ruby SDK Source: https://docs.knoxcall.com/sdks/ruby knoxcall — the official KnoxCall client for Ruby 3.1+. OAuth 2.1 under the hood, zero dependencies (stdlib only). `knoxcall` is the official KnoxCall client for Ruby — stdlib only, Ruby >= 3.1. The client is Mutex-safe: share a single instance across Puma / Sidekiq threads. ## Install ```ruby theme={"dark"} # Gemfile gem "knoxcall", "~> 1.0" ``` Gem: [`knoxcall` on RubyGems](https://rubygems.org/gems/knoxcall). Source: [github.com/KnoxCall/sdk-ruby](https://github.com/KnoxCall/sdk-ruby). ## Create a client ```ruby theme={"dark"} require "knoxcall" # Zero-arg — credentials from `knoxcall login` or the environment # (KNOXCALL_CLIENT_ID, KNOXCALL_CLIENT_SECRET): client = KnoxCall::Client.new # Or OAuth client credentials inline — tenant auto-discovered from the credential. client = KnoxCall::Client.new(client_id: "tk_xxxxxxxx", client_secret: "...") ``` Create the OAuth client in the dashboard under **Settings → API → OAuth Clients** — the steps are in [Create an OAuth client](/api-reference/authentication#create-an-oauth-client). Pass `sandbox: true` to target the isolated Test data plane (`sandbox.knoxcall.com` management host, `sandbox-{tenant}.knoxcall.com` proxy host) with a client created in Test mode. Passing conflicting credential options (e.g. `api_key:` and `client_id:`) raises `ArgumentError` at construction. With no explicit credentials, the SDK resolves in priority order: a pre-acquired token env var (`KNOXCALL_ACCESS_TOKEN` / `KNOXCALL_API_KEY`), the credentials file written by `knoxcall login` (`~/.knoxcall/credentials.json`), then `KNOXCALL_CLIENT_ID` + `KNOXCALL_CLIENT_SECRET`. **Legacy API keys** — a pre-acquired key or token still works everywhere: `KnoxCall::Client.new(api_key: ENV["KNOXCALL_API_KEY"])` accepts a minted `kc_…` token or a legacy `tk_…` / `AKE…` key. It is sent as-is and never auto-renewed — prefer `knoxcall login` or client credentials for new integrations. ### Log in once with the CLI On a developer machine, run [`knoxcall login`](/sdks/overview#log-in-once-with-the-cli) once (this gem ships it: `gem install knoxcall` puts `knoxcall` on your PATH — every KnoxCall SDK ships the same command). It signs you in via your browser and stores the credential in `~/.knoxcall/credentials.json` — this SDK picks the file up automatically, including the stored tenant and base URL, and handles refresh (cross-process safe, atomic rotation of the single-use refresh token). If the stored credential is revoked or expired, the SDK raises `AuthenticationError` telling you to run `knoxcall login` again. Select a profile with `KNOXCALL_PROFILE` (or `bootstrap: KnoxCall::StoredCredentials.new(profile: "staging")`). ```ruby theme={"dark"} client = KnoxCall::Client.new # zero config — tenant and base URL come from the login ``` ## Manage resources Create a route, then list with pagination — single-object methods return the unwrapped Hash; paginated lists return the `{data, meta}` envelope; `each` walks every page transparently (block form or lazy Enumerator): ```ruby theme={"dark"} route = client.routes.create(name: "orders", target_base_url: "https://api.example.com") page = client.secrets.list(page: 2, per_page: 50) page["data"] # => [...] page["meta"]["total"] # => 137 page["meta"]["total_pages"] # => 3 client.secrets.each { |secret| puts secret["name"] } first_ten = client.routes.each(per_page: 5).take(10) # fetches only 2 pages ``` Sub-lists have `each_*` twins: `routes.each_log(id)`, `webhooks.each_log(id)`, `vaults.each_token(name)`. The same pattern covers every resource: `secrets`, `webhooks`, `clients`, `oauth_clients`, `environments`, `api_keys`, `account`, `audit_logs`, `agents`, `crypto`, `pki`, `vaults`, and `dynamic_db`. ## Call routes through the proxy `client.call` proxies a request through a KnoxCall route to your upstream and returns the raw `Net::HTTPResponse` — the upstream's status belongs to you; the SDK never turns it into an error. Reference routes by **slug** (write-once, rename-proof); UUIDs also work. ```ruby theme={"dark"} # GET res = client.call("api-orders", path: "/users") # POST with a body, targeting a specific environment res = client.call("api-orders", method: "POST", path: "/v1/charges", body: { amount: 2000, currency: "usd" }, environment: "staging") res.code # => "201" res.body # raw body string ``` `path` is the **upstream** path. On a KnoxCall cloud tenant host the data plane is served under `/api` (`https://{tenant}.knoxcall.com/api/`); the SDK adds that prefix itself whenever the proxy base is a cloud tenant host with no path of its own, and uses any other base verbatim (self-hosted, or an override that already carries a path). So `/api/v2/tickets` reaches an upstream path that itself begins with `/api`. A per-call `timeout:` overrides the client default. ### Bound routes State the route (and optional defaults) once with `client.route`, then use plain HTTP verbs: ```ruby theme={"dark"} printnode = client.route("api-printnode", environment: "production") res = printnode.get("/computers") res = printnode.post("/printjobs", body: job) res = printnode.request("DELETE", "/printjobs/42") # per-call options still override the bound defaults: res = printnode.get("/computers", environment: "staging") ``` The handle holds no state beyond the defaults — retries, token refresh, and 401 re-mint behave exactly as on `call`. ## Route-aware interception (preview) Send an untouched third-party SDK's traffic through the Route that covers it — and through the ephemeral proxy where no Route does — with no per-SDK wiring. Turn the Route's **Intercept** toggle on and it takes effect on the next request after the 60 s TTL, or on the next refusal, with no code change. The full decision order, what is and is not reached, and the hooks are on [Route-aware interception](/essentials/routes/route-aware-interception). ```ruby theme={"dark"} stop = client.wrap.intercept!(hosts: ["api.resend.com"]) # listed hosts with NO Route go through the ephemeral proxy stop.ready # first manifest loaded Net::HTTP.get(URI("https://api.hubapi.com/crm/v3/objects/contacts")) # any Net::HTTP caller, via the covering Route stop.uninstall ``` `intercept!` is **opt-in and experimental**: it prepends `Net::HTTP#request`, which reaches Faraday's default adapter, `rest-client`, `httparty` and raw Net::HTTP. Not reached: Typhoeus, Curb, `http.rb` (their own socket layer) — point those SDKs at a gateway URL. The same decisions are available without the process-wide seam: `client.wrap.faraday_connection(routes: :auto)` for an SDK that takes an injected connection (controls on `conn.knoxcall`), and `conn.builder.insert_before(Faraday::Adapter, *client.wrap.faraday_middleware(hosts: [...]))` for a stack the SDK builds itself. Per-host options: `hosts: { "api.resend.com" => { credential: { secret: "resend-key" } } }` (escrow) or `{ unavailable: :direct }` (transit only); `require_context: true` limits interception to code inside `client.wrap.routed { }`. `KNOXCALL_INTERCEPT=off` is the kill switch. ## Verify webhooks `KnoxCall::Client.construct_event` (also available as `client.construct_event` and `client.webhooks.construct_event`) verifies the delivery's HMAC-SHA256 signature and parses it into an event Hash in one step. Pass the raw body string — never re-serialized JSON: ```ruby theme={"dark"} post "/webhooks/knoxcall" do event = KnoxCall::Client.construct_event(request.body.read, request.env, ENDPOINT_SECRET) case event["event"] when "audit.event" log.info "audit: #{event['data']['action']}" else # request.* log.info "#{event['data']['route_name']} -> #{event['data']['response']['status']}" end 200 rescue KnoxCall::WebhookSignatureVerificationError halt 400, "bad signature" end ``` Formats beyond the default `legacy` header (`stripe`, `github`, `slack`, `aws-sns`, `custom`) are selected with `format:`; the replay window defaults to 300s (`tolerance_seconds: nil` disables it); `custom` requires `header_name:`. All comparisons are constant-time. ## Handle errors All API failures are typed subclasses of `KnoxCall::Error`: ```ruby theme={"dark"} begin client.routes.create(name: "orders", target_base_url: url) rescue KnoxCall::RateLimitError => e sleep e.retry_after || 1 rescue KnoxCall::APIError => e logger.error "#{e.status_code}: #{e.message}" # message carries the server's request_id context end ``` Hierarchy: `AuthenticationError` (401), `PermissionDeniedError` (403), `NotFoundError` (404), `RateLimitError` (429), `ServerError` (5xx), all under `APIError` (with `status_code` / `headers` / `body`); plus `SignupError` (with `status_code` / `error_type` / `request_id`), `WebhookSignatureVerificationError`, `TokenError`, and `NetworkError` / `ConnectionTimeoutError` for transport failures. ## Retries and idempotency Management requests retry automatically on transport errors and HTTP 408/429/500/502/503/504 (never 409), with exponential half-jitter backoff and `Retry-After` honored up to 30s. Every mutating request carries a ULID `X-Idempotency-Key` that stays stable across retries. A 401 purges the cached token and retries once with fresh credentials. Data-plane calls never replay a mutating request after it may have reached the wire. Tune with `retry_max_attempts:`, `retry_base_delay:`, `retry_max_delay:`. ## DPoP For higher-security tenants, enable sender-constrained tokens (RFC 9449) — the SDK generates an ES256 keypair, binds the access token via `cnf.jkt`, and signs a fresh proof per request: ```ruby theme={"dark"} client = KnoxCall::Client.new(tenant: "acme", dpop: "always") ``` In the default `"auto"` mode the SDK starts with plain Bearer tokens and upgrades to DPoP automatically when the OAuth client record requires it (`require_dpop`): the token endpoint answers `invalid_dpop_proof`, the SDK generates a keypair, retries once, and operates as DPoP from then on. `dpop: "never"` opts out — if the server issues a DPoP-bound token anyway, the SDK raises `KnoxCall::TokenError` rather than 401-looping. ## Full reference The package README documents every resource method, the ephemeral proxy, crypto/PKI/vault operations, and credential-less `KnoxCall.signup`: [github.com/KnoxCall/sdk-ruby](https://github.com/KnoxCall/sdk-ruby#readme). # Terraform Provider Source: https://docs.knoxcall.com/sdks/terraform terraform-provider-knoxcall — manage KnoxCall routes, environments, secrets, clients, webhooks and access credentials as code through the /v1 Management API. The official Terraform provider manages KnoxCall resources through the `/v1` Management API. It is built with the Terraform Plugin Framework and speaks plugin protocol 6. This page is the **guide**: how it authenticates and what it does with your secrets. Per-attribute reference documentation is generated from the provider schema and ships with the provider. **Not installable yet.** The provider has no released version: it is not on the Terraform Registry or the OpenTofu registry, and its source is not public. A `terraform init` against `source = "knoxcall/knoxcall"` fails today, and there is no other way to install it. This page documents the provider ahead of its release. ## Requirements * **Terraform >= 1.11 or OpenTofu >= 1.11** for any resource carrying a write-only attribute — which is every resource that accepts secret material. The two ecosystems shipped write-only attributes in different years; 1.11 is the floor in both. * Terraform >= 1.10 for the ephemeral resource. * A KnoxCall tenant, and an API key or OAuth client for it. ## Install There is nothing to install yet. When a release exists, this section will carry the `required_providers` block that fetches it. Until then, manage the same resources in the dashboard, through the [`/v1` Management API](/api-reference/overview), or from one of the [SDKs](/sdks/overview). ## Authenticate The 90% path is an OAuth client, and it is the default: ```hcl theme={"dark"} provider "knoxcall" { # Prefer environment variables over committed values: # KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET client_id = var.knoxcall_client_id client_secret = var.knoxcall_client_secret # Asserted against the tenant the credential actually resolves to. A # mismatch fails the apply on the first token mint, before any write. tenant_slug = "acme" } ``` Alternatively configure a single `api_key` (or `$KNOXCALL_API_KEY`) — a tenant API key (`tk_…`) or a pre-minted OAuth token (`kc_…`). It conflicts with `client_id`/`client_secret`. Even with `api_key`, the default `auth_mode = "auto"` still authenticates with OAuth: the key is used as *client credentials* (its `tk_…` prefix is the client id, the whole key is the secret) to mint a short-lived access token, and only falls back once — with a warning — to sending the raw key as a bearer token if the token endpoint answers `invalid_client`. | `auth_mode` | Behaviour | | - | - | | `"auto"` (default) | Mint an OAuth token; fall back to bearer once, with a warning, on `401 invalid_client`. | | `"oauth"` | Mint an OAuth token; a rejected mint is a hard, actionable error with no fallback. | | `"bearer"` | Legacy static key. Requires `api_key`. | `auth_mode = "bearer"` is the legacy escape hatch. On a tenant with `bootstrap_only_keys` — the default for recently created tenants — every bearer request fails with `401 use_oauth`. If you are starting today, use OAuth. `tenant_slug` deliberately has **no environment-variable fallback**. An assertion whose expected value comes from the same environment as the credential asserts nothing. ## Sandbox Live and Test are separate data spaces with separate credentials. One provider block manages one `(tenant, data space)` pair, so managing both means two provider blocks: ```hcl theme={"dark"} provider "knoxcall" { alias = "live" # sandbox defaults to false client_id = var.live_client_id client_secret = var.live_client_secret } provider "knoxcall" { alias = "test" sandbox = true # or $KNOXCALL_SANDBOX client_id = var.test_client_id client_secret = var.test_client_secret } resource "knoxcall_route" "staging_weather" { provider = knoxcall.test name = "weather-api" target_base_url = "https://api.weatherapi.com" } ``` `sandbox = true` selects the Test base URL. An explicit `base_url` wins over the flag. The credential must belong to the data space it is pointed at — a mismatch is a server-side `403 wrong_key_type`, not a silent cross-space write. ## Secrets and Terraform state ### What the provider will not write to state **There is no plain `value` attribute on any secret resource** — not a sensitive one, not an optional one, not a deprecated one. Secret material you supply is supplied through *write-only* attributes: Terraform reads them from your configuration, the provider sends them to the API, and the framework nullifies them before they can reach a plan file, a state file, or the raw `terraform.tfstate` bytes. The `/v1` API never returns a stored secret value, so there is nothing to read back on refresh either. Each write-only attribute is paired with a persisted `_wo_version` integer. Terraform can never see a write-only value, so that integer is the entire mechanism by which a rotation is expressible: | Resource | Write-only attribute | Version companion | | - | - | - | | `knoxcall_secret` | `value_wo` | `value_wo_version` | | `knoxcall_secret_oauth2` | `client_secret_wo` | `client_secret_wo_version` | | `knoxcall_secret_oauth2` | `password_wo` | `password_wo_version` | | `knoxcall_secret_certificate` | `private_key_wo` | `private_key_wo_version` | | `knoxcall_secret_certificate` | `passphrase_wo` | `passphrase_wo_version` | | `knoxcall_client_credential` | `data_wo` | `data_wo_version` | ```hcl theme={"dark"} resource "knoxcall_secret" "stripe" { name = "stripe-live-key" value_wo = var.stripe_key # never written to plan or state value_wo_version = 1 # bump to rotate } ``` **The proof, not just the claim.** An acceptance gate in the provider's own test suite (`internal/provider/state_safety_gate_test.go`) iterates every registered write-only attribute and, for each, applies a real configuration carrying a unique sentinel value, then requires that sentinel to appear nowhere in the parsed state, nowhere in the plan at either pre-apply or post-refresh, and nowhere in the raw bytes on disk — while separately proving the value *did* reach the API, because a provider that silently dropped every write-only value would otherwise score perfectly. A host-side scanner then checks the server's own idempotency and audit stores for the same sentinel. The gate is exercised on Terraform 1.11.4 — the floor, which also proves the write-only cases *skip* rather than error on an older CLI — and on the current release. ### What *is* in state, and why Four attributes hold credentials **the platform mints and returns exactly once**. They are `Computed` and `Sensitive` — redacted in CLI output, written to state in cleartext: | Attribute | The state-safe alternative | | - | - | | `knoxcall_webhook.secret_key` | Set `hmac_key_id` to a KnoxCall crypto key. The signing material then stays inside KnoxCall, is returned by no API response, and rotating it changes nothing in Terraform. | | `knoxcall_client_credential.private_key_pem` | Register a certificate you already hold — `data_wo` = `{"sha256_thumbprint": "…"}` — instead of `{"mode": "issue"}`. Nothing is minted, so nothing is returned. | | `knoxcall_api_key.api_key` | None on this resource: an API key *is* the credential. The alternative is not to mint keys from Terraform. | | `knoxcall_oauth_client.client_secret` | None today. It is null for a `public` client, which has no secret at all. | This is a limit of the plugin framework, not a choice: `Computed` and `WriteOnly` are mutually exclusive, so no schema both captures a value the server generates and refuses to persist it. Rather than leave it implicit, each of the four is registered in `internal/provider/wo_guarded_attributes.go` with a declared classification and the plan item that would remove it, and a guard fails the build if an attribute carrying secret material is neither write-only nor listed there — or if a listed exception outlives the attribute it excused. The recommended webhook configuration is therefore the one with no signing secret in it at all: ```hcl theme={"dark"} resource "knoxcall_webhook" "alerts" { name = "request-errors" url = "https://hooks.example.com/knoxcall" event_types = ["request.server_error", "request.timeout"] hmac_key_id = var.knoxcall_signing_key_id # signing material never leaves KnoxCall hmac_format = "github" } ``` ### Two blind spots you need to know about Both are properties of write-only attributes in general rather than of this provider, and both will bite somebody who has not read them. **1. An out-of-band rotation is invisible to `terraform plan` unless you pin `value_version`.** Terraform never holds the value, so it cannot diff it. Left unset, `knoxcall_secret.value_version` reports a server-side rotation only in refresh output — never as a planned action, and not at all under `terraform plan -refresh=false`, which is standard in CI. Pin it and the same rotation becomes a real diff with a non-zero `terraform plan -detailed-exitcode`: ```hcl theme={"dark"} resource "knoxcall_secret" "stripe" { name = "stripe-live-key" value_wo = var.stripe_key value_wo_version = 1 value_version = 7 # assertion: KnoxCall's own rotation counter } ``` A pinned version is an assertion, not an instruction — no API sets the counter. If it stops matching, the apply fails and names the two ways out: re-pin to accept the rotation, or bump `value_wo_version` to rotate through Terraform. It is deliberately suppressed for `secret_type = "custodial"`, where KnoxCall owns the value and may rotate it hourly. **2. Editing a write-only value without bumping its `_wo_version` companion is inert.** The new value is never sent, no diff appears, and the apply reports success. Drive the value and its version from one variable where you can. Two consequences worth stating once: the value your configuration declares is *intent*, `value_version` is *reality*, and the tenant audit log is *history* — every rotation Terraform performs is recorded server-side as a `rotate` event, which is where the change history lives given a plan cannot show it. And a secret's value is held per environment server-side, but the provider models only the base environment's value today; there is no resource for a per-environment value override. ## Resources Every resource supports create, read, delete and `terraform import`. All but two also update in place: KnoxCall has no PATCH for route actions or API keys, so every argument of `knoxcall_route_action` and `knoxcall_api_key` forces a replacement rather than an in-place edit. Import IDs are frozen ahead of first publish, because an import ID is a public interface. | Resource | What it manages | Import ID | | - | - | - | | `knoxcall_environment` | A tenant environment | `` | | `knoxcall_route` | A proxy route | `` | | `knoxcall_route_environment` | A route's per-environment override | `/` | | `knoxcall_route_action` | A field transform on a route | `/` | | `knoxcall_secret` | A string secret | `write-only:` or `` | | `knoxcall_secret_oauth2` | An OAuth2 connection secret | `write-only:` or `` | | `knoxcall_secret_certificate` | A certificate + private key secret | `write-only:` or `` | | `knoxcall_client` | A data-plane caller | `` | | `knoxcall_client_credential` | A credential belonging to a client | `/` | | `knoxcall_webhook` | A webhook subscription | `` | | `knoxcall_api_key` | A tenant API key | the `tk_…` key id, **not** the UUID | | `knoxcall_oauth_client` | An OAuth client | `` | The `write-only:` prefix seeds the `_wo_version` companion to `1`, so a configuration that already declares the pair at version 1 gives an empty first plan rather than proposing to rewrite the credential: ```sh theme={"dark"} terraform import knoxcall_secret.stripe write-only:9c8b7a65-4321-4def-9012-3456789abcde terraform import knoxcall_route_environment.staging 3f2a1b4c-5d6e-4f70-8912-abcdef012345/staging terraform import knoxcall_api_key.ci tk_live_abc123 ``` No import form recovers a secret's value — KnoxCall never returns a stored value, which is the property the resource exists to preserve. Put the value back in configuration as `value_wo`; it is not re-sent until `value_wo_version` changes. For the same reason an imported `knoxcall_webhook` has a null `secret_key`, and an imported `knoxcall_client_credential` a null `private_key_pem`. **Data sources** — `knoxcall_account`, `knoxcall_client`, `knoxcall_environment`, `knoxcall_environments`, `knoxcall_route`, `knoxcall_secret`, `knoxcall_secrets`, `knoxcall_webhook_event_types`. None of them can return secret material, and a test drives every one against a stub returning credential-shaped fields and then searches the state artifact to prove it. **Ephemeral resources** — `knoxcall_oauth2_access_token` hands the live upstream access token behind a `knoxcall_secret_oauth2` to the consuming resource for the duration of one operation, and never writes it to state. KnoxCall owns the refresh, so the upstream refresh token never leaves the platform. ```hcl theme={"dark"} ephemeral "knoxcall_oauth2_access_token" "crm" { secret_id = knoxcall_secret_oauth2.crm.id environment = "production" } ``` Terraform opens ephemeral resources during `plan` when every argument is known, so a plan mints a real token; referencing an unknown attribute — as above, the id of a resource created in the same run — defers the fetch to apply. Do not route the token through an `output`: outputs are stored in state, and Terraform refuses an ephemeral value there unless the output is itself declared `ephemeral = true`. ## Behavior The provider's API client follows the [SDK parity standard](/sdks/overview) where applicable: * **Retries:** 408/429/500/502/503/504 retried up to 3 attempts with half-jitter exponential backoff (100ms base, 5s cap). 409 is never retried. On 429 a server `Retry-After` is honored, capped at 30s. * **Idempotency:** every mutating request carries a ULID `X-Idempotency-Key` generated once per logical request — stable across retries, so transport failures are also safe to retry. * **401:** access tokens are minted via the `client_credentials` grant and refreshed proactively before expiry; an unexpected 401 on a resource request purges the cached token and re-mints once before failing with a clear diagnostic. Under `auth_mode = "bearer"` there is nothing to re-mint, so a 401 fails fast. * **Secret hygiene:** `api_key` and `client_secret` are `Sensitive` in the schema and never appear in errors, logs, or diagnostics (`client_id` is a public identifier and stays legible so a rejected client is identifiable). * **Cancellation:** all requests and retry backoffs respect Terraform's operation context — interrupts abort promptly. Every error diagnostic ends with the request id from the `/v1` response envelope: ``` Request ID: 7b1f6a2c-4d3e-4a91-8c07-2f5b9e0d1a34 — quote this when contacting KnoxCall support. ``` Quote it in a support request or a bug report; it is what correlates your failure with the server's own logs. ## Where to go next * [Provision with Terraform](/getting-started/provision-with-terraform) — the first route, environment and secret as \~40 lines of HCL. # Agent Security Model Source: https://docs.knoxcall.com/security/agent-security-model How the KnoxCall Go Client Agent and self-hosted proxy container resist theft, cloning, and tampering — every layer from compile-time to request-path. # Agent Security Model The KnoxCall agent (both the Go Client Agent and the self-hosted `knoxcall/proxy` container) handles your credentials. Our job is to make **every theft scenario** harder than it looks — not just the obvious ones. This page enumerates the threats we defend against and the layers that mitigate them. ## Threat Model | Threat | Mitigation | | - | - | | Attacker copies the agent binary to a new machine | [Machine-fingerprint binding](#machine-fingerprint-binding) refuses a session that presents a different fingerprint, or none at all, once the agent is bound — see the section for what this does and does not prove | | Attacker modifies the agent binary | [Tamper seal](#tamper-seal) fails signature verification; control plane refuses sessions | | Attacker captures the TLS-decrypted session request | [Nonce + timestamp](#replay-protection) refuse a verbatim re-send. The captured body still carries the agent credential, so a capture yields fresh sessions until the agent is revoked — see the section for what replay protection does and does not buy | | Attacker steals the disk cache | [Encrypted persistence](#encrypted-disk-cache) without the session key inside | | Attacker dumps agent memory | [Session rotation](#session-rotation) limits usable secret lifetime to the current hour | | Attacker compromises agent credentials | [Admin revocation](#revocation) with ≤5-minute propagation | | Rogue administrator on a stolen host | [Single-tenant guard](#single-tenant-enforcement) prevents cross-tenant access | ## Credential Hierarchy Three tiers of key material, each with a different blast radius: ```text theme={"dark"} MASTER_KEY_B64 ← stays in KnoxCall cloud (never leaves) │ └─ wraps CEKs for each secret (envelope encryption) │ └─ session_key ← derived per agent + per hour (HKDF) │ ← re-encrypts CEKs when shipped in a bundle │ ← never written to disk └─ lives in agent memory for ≤ 1 hour ``` Key properties: * **MASTER\_KEY\_B64** never leaves the control plane. Stealing an agent gives you zero progress toward decrypting it. * **Session keys** are HKDF-derived per `(agent_id, window_hour)`. The derivation is stateless — the control plane re-computes on each fetch without storing the key anywhere. * **Window rotation** means a stolen session key from 14:00 UTC is worthless at 15:00 UTC, even without revocation. ## Machine-Fingerprint Binding The self-hosted proxy container and the Node intercept runtime include a `machine_fingerprint` on every `/agent/v1/session` request — a SHA-256 of stable host signals: * `/etc/machine-id` (systemd) * Primary non-loopback MAC address * OS hostname On **first successful fetch**, the control plane stores the fingerprint and sets `machine_fingerprint_locked=true`. Every subsequent fetch must then send that same fingerprint — otherwise the control plane: 1. Refuses the session with `403 fingerprint_mismatch` when a different fingerprint is presented, or `403 fingerprint_required` when the field is omitted, empty or not a string 2. Logs a tamper event visible on the agent detail page, in both cases 3. Keeps the agent's existing binding intact (the thief's fingerprint is **not** recorded) **What this does and does not prove.** The binding is trust-on-first-fetch: the control plane has no way to tell which machine is legitimate before the first session request, so the **first holder of the credentials to call `/agent/v1/session` is the one that binds** — including a thief who gets there first, and including a machine that is not the one you installed on. The fingerprint is also computed by the agent from host signals that anything on that host can read, and can be set outright with `KNOXCALL_MACHINE_FINGERPRINT` (below), so it is a *change detector* for an honest agent that has been moved, not proof of which hardware is calling. Agents running in control mode never fetch a session bundle at all, so they are never bound by this mechanism. Enrolment-time attestation and a host-held identity key — which do prove the machine — are on the roadmap; until they ship, treat this binding as a tripwire and rely on credential rotation and revocation for containment. An admin who legitimately moves an agent to a new host can **reset** the binding via `POST /admin/agents/:id/reset-fingerprint`. The next fetch rebinds. Override the fingerprint with `KNOXCALL_MACHINE_FINGERPRINT=` when the agent legitimately runs on ephemeral instances that share an identity (e.g. auto-scaling groups behind a warm-pool image). The **Go client agent** does not send a fingerprint at all, so a registration it uses alone is never bound and this section does not apply to it. If you point a Go agent at a registration that a proxy container has already bound, its session is refused with `403 fingerprint_required` — clear the binding from the agent's detail page, or give each runtime its own registration. ## Tamper Seal Every agent binary carries a `build_sig` = HMAC-SHA256(AGENT\_BUILD\_SECRET, `hash:version`). Two-pass build flow: 1. Compile with placeholder sig → hash the compiled artefact 2. HMAC with the CI-only secret → re-compile with real sig baked in On every session fetch the control plane looks up the sig in `agent_versions`: * **Known sig** → session issued normally. * **Unknown sig** → tamper event logged. Default mode continues with a warning; **strict mode** (`KNOXCALL_STRICT_TAMPER_CHECK=true`) denies the session. The signing key lives only in CI/CD — never shipped in any binary. A compromised agent can't generate a valid sig for a modified version. ## Replay Protection Session fetches carry: * `nonce` — 16 random bytes, base64-hex * `timestamp` — seconds since epoch The control plane: 1. Rejects requests with timestamp drift > 5 min (clock-sync check) 2. Hashes the nonce and inserts into `agent_session_nonces` with a unique index on `(agent_id, nonce_hash)` → a replay hits the unique constraint and is refused 3. Cleans up nonces older than 1 hour via the log-cleanup cron Effect: a captured session request cannot be re-sent verbatim. That is all it buys. The body of the request carries `agent_id` and `agent_secret`, so whoever holds one TLS-decrypted body holds the credential itself and can build fresh requests — a new nonce, the current timestamp — until the agent is revoked or its secret rotated. Nonce and timestamp bound *replay*, not *possession*; nothing in this handler signs the body, and a possession-proof identity for the agent (a host-bound key, plan Phase 6) is what closes the second half. Two properties make the replay bound hold rather than merely intend it. The pair is **required**, not optional — an agent that reports a version cannot have its replay check skipped by omitting the fields, and is refused with `401 nonce_required`. And if the nonce store itself is unavailable, the session is refused with `503 replay_guard_unavailable` rather than issued unchecked; the agent retries. The one exception is a request that reports no version at all, which is still accepted without the pair for backward compatibility; that window closes on 2027-03-31. The Go client agent reports version 1.0.0 and is covered by that exception today; the self-hosted proxy container and the Node intercept runtime both send the pair and are held to it. ## Encrypted Disk Cache The proxy persists a disk cache at `/var/lib/knoxcall/session.json` so a container restart can serve traffic during the first sync. Security properties: * **Never contains the session key.** `session_key_b64` is scrubbed before write. * **Encrypted at rest with AES-256-GCM**, key derived from `MASTER_KEY_B64` via HKDF-SHA256 with a per-write random salt. * **File mode `0600`** (owner read/write only). * **Magic-prefix** marks the cache format version; old plaintext caches are discarded on upgrade. * **On load without a live session key**, the proxy serves routes + environments + api\_keys from the cache but returns `503` for requests that need secret injection until the next live sync repopulates `decryptedSecrets`. An attacker who steals just the cache file gets the re-encrypted secret blobs. Without the session key (which rotates hourly and is never on disk), the blobs are indistinguishable from random. Disable persistence entirely with `KNOXCALL_PROXY_BUNDLE_CACHE_PATH=off`. ## Session Rotation * **Session duration:** 1 hour (default) * **Session duration for Customer KMS (BYOK) tenants:** 5 minutes — intentionally shorter to bound plaintext exposure if the customer revokes KMS access * **Renewal trigger:** 55 minutes for standard (5-minute grace before expiry); 4 minutes for BYOK (1-minute grace before expiry) * **Stale-session deadman:** at `expires_at`, the proxy flips `/healthz` to 503 and refuses new requests until a successful refresh Even without revocation, a stolen session key stops working within its session duration window. ## Memory Hygiene On graceful shutdown (`SIGTERM`, `SIGINT`): * Every entry in the decrypted-secrets map is overwritten with empty string before the map is dropped * The in-memory session key is zeroed * The disk cache is updated one last time (without the session key) This reduces the window in which a post-mortem heap dump could recover plaintext. JavaScript strings are immutable, so we can't guarantee full secure-wipe — that requires an OS-level memory-scrub primitive. What we do guarantee: no references held, no plaintext in on-disk cache. ## Single-Tenant Enforcement When `DEPLOYMENT_MODE=self_hosted`, every request goes through a tenant guard in the proxy router. If the resolved tenant ID differs from `KNOXCALL_TENANT_ID`: * Request returns `404` (not `403` — we don't reveal whether the other tenant exists) * Control plane refuses to issue session bundles containing any other tenant's data Even if an attacker stole credentials from one customer's self-hosted install and repurposed them to proxy a different tenant's domain, the binding check denies the session upfront. ## Revocation Admin UI → Automation → Agents → select agent → Revoke. * Sets `agent_registrations.status = 'revoked'` immediately * Existing in-memory session remains valid until `expires_at` (≤ 55 minutes) * Next renewal attempt fails with `403 agent_revoked` For faster kill: also rotate `MASTER_KEY_B64` — the session key derivation depends on it, so a key rotation invalidates every agent session system-wide within minutes. ## What We Don't Protect Against Being honest about the limits: * **Full root on the agent host** — if the attacker owns the kernel, they can read the live session key out of process memory. This is true of every secrets-management tool. Mitigate with host hardening (SELinux, AppArmor, minimal-surface containers). * **Compromised control plane** — if knoxcall.com itself is compromised, the attacker has the `AGENT_BUILD_SECRET` and `MASTER_KEY_B64` and can forge anything. Our defence is that the control plane lives behind our security perimeter with a small attack surface and audited operator access. * **Insider with database access** — a KnoxCall operator with prod-DB access can read any tenant's re-encrypted secrets. Mitigated by break-glass audit logging on all prod access; true air-gap requires the [Phase-1B fully self-hosted](/infrastructure/migration) architecture. If your threat model requires defending against the last two, reach out about the fully self-hosted roadmap. # AI-Powered Threat Detection Source: https://docs.knoxcall.com/security/ai-threat-detection Intelligent threat detection and security monitoring powered by machine learning. Automatically identify suspicious patterns, anomalies, and potential attacks. **PLANNED FEATURE - NOT YET AVAILABLE** This feature is currently in development and planned for release in Q3 2025. The documentation below describes the planned functionality and is subject to change. For current security features, see [Rate Limiting](/advanced/rate-limiting), [Request Signing](/security/request-signing), and [OAuth2 Security](/security/oauth2-flow). # AI-Powered Threat Detection Protect your APIs with intelligent threat detection. KnoxCall's AI engine analyzes request patterns in real-time to identify suspicious activity, potential attacks, and security anomalies before they cause damage. ## What is AI Threat Detection? Traditional security relies on manual rules and fixed thresholds. AI Threat Detection uses machine learning to: * 🧠 **Learn normal behavior** for your APIs * 🚨 **Detect anomalies** automatically * 🎯 **Identify attack patterns** (SQL injection, XSS, brute force) * 🔮 **Predict threats** before they escalate * 📊 **Adapt continuously** as your traffic evolves ## How It Works ### 1. Baseline Learning KnoxCall AI learns your normal traffic patterns: ```text theme={"dark"} Week 1: Learning Phase ├─ Normal request volume: 1,000-5,000/hour ├─ Common endpoints: /api/users, /api/orders ├─ Typical latency: 50-200ms ├─ Standard error rate: 1-2% └─ Geographic distribution: 60% US, 30% EU, 10% APAC ``` ### 2. Anomaly Detection AI compares real-time traffic against baseline: ```text theme={"dark"} Current Activity: ├─ Request volume: 50,000/hour ⚠️ (10x normal) ├─ New endpoint: /api/admin/delete-all ⚠️ ├─ Latency: 5,000ms ⚠️ (10x normal) ├─ Error rate: 45% ⚠️ (23x normal) └─ Location: Russia ⚠️ (never seen before) AI Assessment: 🚨 HIGH THREAT PROBABILITY ``` ### 3. Threat Classification AI categorizes threats by type: ```text theme={"dark"} 🔴 Critical: Active attack in progress 🟠 High: Suspicious pattern detected 🟡 Medium: Unusual activity 🟢 Low: Minor anomaly ``` ### 4. Automated Response Based on threat level, KnoxCall can: * **Alert** admins via email/Slack/SMS * **Rate limit** suspicious IPs * **Block** confirmed attacks * **Log** detailed forensics * **Generate** security reports ## Detected Threat Types ### 1. Brute Force Attacks **Pattern:** ```text theme={"dark"} IP: 192.168.1.100 Request: POST /api/login Status: 401 Unauthorized Frequency: 1,000 attempts in 5 minutes ``` **AI Detection:** * Rapid successive failures * Same IP, different credentials * Exponential retry pattern **Response:** * Block IP after 10 failed attempts * Rate limit: 1 request/minute * Alert security team ### 2. SQL Injection Attempts **Pattern:** ```text theme={"dark"} GET /api/users?id=1' OR '1'='1 GET /api/search?q=admin'-- POST /api/data {"name": "test'; DROP TABLE users;--"} ``` **AI Detection:** * SQL keywords in parameters * Quote and comment patterns * UNION, DROP, SELECT in unexpected places **Response:** * Block request immediately * Log full payload * Alert with OWASP category ### 3. API Abuse / Scraping **Pattern:** ```text theme={"dark"} IP: 203.0.113.50 Requests: 10,000 in 10 minutes Endpoints: /api/products/1, /api/products/2, ... /api/products/10000 User-Agent: python-requests/2.28.0 ``` **AI Detection:** * Sequential ID enumeration * Suspicious user agent * No typical browsing pattern * Ignores rate limit headers **Response:** * Rate limit aggressively * Require CAPTCHA * Block after threshold ### 4. Credential Stuffing **Pattern:** ```text theme={"dark"} 1000 login attempts with: - Different usernames - Different passwords - Same IP or distributed botnet - Known leaked credential combinations ``` **AI Detection:** * Cross-reference with breach databases * Unusual geographic distribution * Coordinated timing * Low success rate **Response:** * Block IP ranges * Require 2FA * Force password reset ### 5. DDoS Attacks **Pattern:** ```text theme={"dark"} Sudden traffic spike: - Normal: 1,000 req/min - Attack: 100,000 req/min - From: 10,000 unique IPs - Target: Single endpoint ``` **AI Detection:** * Abnormal traffic volume * Distributed sources * Identical request patterns * Low latency variance (bots) **Response:** * Activate DDoS mitigation * Enable challenge response * Rate limit globally * Contact CDN/ISP ### 6. Account Takeover Attempts **Pattern:** ```text theme={"dark"} User login from: - New device - New location (5,000 miles away) - Within 5 minutes of previous login - Different user agent ``` **AI Detection:** * Impossible travel * Device fingerprint mismatch * Unusual access patterns * Credential change attempts **Response:** * Require additional verification * Lock account temporarily * Alert user via email * Log forensic details ## AI Features ### Behavioral Analysis Track individual client behavior: ```text theme={"dark"} Client: mobile-app-ios Normal Behavior: ├─ Active: 9 AM - 9 PM EST ├─ Endpoints: /api/feed, /api/profile, /api/posts ├─ Frequency: 10-50 requests/hour ├─ Locations: US, Canada └─ Success rate: 98% Anomaly Detected: ├─ Time: 3 AM EST ⚠️ ├─ Endpoint: /api/admin/users ⚠️ ├─ Frequency: 500 requests/minute ⚠️ ├─ Location: Unknown VPN ⚠️ └─ Success rate: 20% ⚠️ Threat Level: 🔴 CRITICAL ``` ### Pattern Recognition Identify complex attack patterns: ```text theme={"dark"} Attack Pattern: Advanced Persistent Threat (APT) ├─ Phase 1: Reconnaissance (scanning endpoints) ├─ Phase 2: Exploitation (SQL injection attempts) ├─ Phase 3: Privilege escalation (admin API calls) └─ Phase 4: Data exfiltration (bulk downloads) AI detected all 4 phases and blocked at Phase 2 ``` ### Predictive Alerts Get warnings before attacks happen: ```text theme={"dark"} 🔮 Predictive Alert: Potential Attack ├─ Confidence: 85% ├─ Type: Brute force attack ├─ Target: /api/login ├─ Predicted timing: Next 30 minutes └─ Recommended action: Enable rate limiting ``` ## Configuration ### Step 1: Enable AI Threat Detection 1. Navigate to **Security** → **AI Threat Detection** 2. Toggle **Enable AI Detection** to ON 3. Choose sensitivity level: ```text theme={"dark"} Low: Only critical threats Medium: High and critical threats (recommended) High: All anomalies ``` ### Step 2: Configure Response Actions Set automated responses: ```yaml theme={"dark"} Critical Threats: - Block IP immediately - Send SMS alert - Log to SIEM High Threats: - Rate limit to 10 req/min - Send email alert - Monitor for 1 hour Medium Threats: - Log and monitor - Send daily summary Low Threats: - Log only ``` ### Step 3: Define Protected Routes Choose which routes to protect: ```text theme={"dark"} Route: payment-api AI Protection: Maximum Response: Block on threat Route: public-api AI Protection: Medium Response: Rate limit on threat Route: internal-tools AI Protection: Low Response: Alert only ``` ## Security Dashboard ### Threat Overview Real-time threat monitoring: ```text theme={"dark"} 🟢 System Status: SECURE 📊 Threats Detected (24h): 47 🚫 Attacks Blocked: 38 ⚠️ Active Investigations: 2 ``` ### Threat Feed Live feed of detected threats: ```text theme={"dark"} [12:45:32] 🔴 CRITICAL: SQL Injection blocked IP: 203.0.113.100 Route: /api/search Payload: admin'-- Action: Blocked [12:42:15] 🟠 HIGH: Brute force detected IP: 198.51.100.50 Route: /api/login Attempts: 50 in 2 minutes Action: Rate limited [12:38:04] 🟡 MEDIUM: Unusual traffic pattern Client: mobile-app-android Volume: 5x normal Action: Monitoring ``` ### Threat Map Geographic visualization of threats: ```text theme={"dark"} 🔴 5 attacks (Europe) │ 🟠 12 threats (North America) │ 🟡 3 anomalies (Asia Pacific) ``` ## Integration with External Tools ### SIEM Integration Send threat data to your SIEM: ```text theme={"dark"} Configure Splunk HTTP Event Collector Send threats as JSON events Create dashboards and alerts ``` ```text theme={"dark"} Use Datadog API integration Send threat events as logs Create security monitors ``` ```text theme={"dark"} Configure HTTP source Stream threat data Build security analytics ``` ### Webhook Notifications Send threats to custom endpoints: ```javascript theme={"dark"} POST https://your-security-system.com/threats { "threat_id": "thr_abc123", "severity": "critical", "type": "sql_injection", "ip": "203.0.113.100", "route": "/api/search", "timestamp": "2025-01-20T15:30:00Z", "details": { "payload": "admin'--", "action_taken": "blocked" } } ``` ## Machine Learning Models KnoxCall uses multiple ML models: ### 1. Anomaly Detection Model **Algorithm**: Isolation Forest **Training**: Continuous on your traffic **Features**: 50+ traffic characteristics **Accuracy**: 95%+ true positive rate ### 2. Threat Classification Model **Algorithm**: Gradient Boosting (XGBoost) **Training**: 10M+ labeled attack samples **Output**: Threat type and confidence **Accuracy**: 98% classification accuracy ### 3. Pattern Recognition Model **Algorithm**: LSTM Neural Network **Purpose**: Detect multi-stage attacks **Window**: Analyzes 1-hour sequences **Accuracy**: 92% attack chain detection ## Best Practices ### 1. Start with Learning Mode Let AI learn for 1-2 weeks before enforcing: ```text theme={"dark"} Week 1-2: Learning mode (alerts only) Week 3+: Enforcement mode (block threats) ``` ### 2. Review False Positives Check alerts weekly: ```text theme={"dark"} False Positive Rate: < 1% Review: Weekly Action: Whitelist legitimate patterns ``` ### 3. Tune Sensitivity Adjust based on your risk tolerance: ```text theme={"dark"} High Security: High sensitivity Balanced: Medium sensitivity (default) Performance Critical: Low sensitivity ``` ### 4. Combine with Other Security AI works best alongside: * ✅ Rate limiting * ✅ IP whitelisting * ✅ Request signing * ✅ WAF rules ### 5. Monitor and Adjust Review AI performance monthly: ```text theme={"dark"} Metrics to track: - Detection rate - False positive rate - Response time - Blocked threats ``` ## Troubleshooting ### High false positive rate **Solutions:** * Lower sensitivity * Whitelist known-good IPs * Add legitimate patterns to training ### Attacks getting through **Solutions:** * Increase sensitivity * Enable additional security layers * Update threat intelligence feeds ### Performance impact **Solutions:** * Enable sampling (analyze 10% of traffic) * Use async threat detection * Upgrade to higher tier ## Next Steps Add cryptographic verification Complement AI with rate limits Configure threat alerts View security analytics *** * **Level**: advanced * **Time**: 20 minutes `security`, `ai`, `ml`, `threat-detection`, `protection` # Managing Client Permissions Source: https://docs.knoxcall.com/security/client-permissions Control client access to routes, manage API keys, and monitor client activity. Intermediate guide. # Managing Client Permissions Control which routes clients can access and manage API keys securely. ## Route Permissions ### Restrict Client to Specific Routes **Why?** Give partner integrations access only to what they need. **How:** 1. Edit client 2. Under **Allowed Routes**, select specific routes 3. Save Now this client can only access those routes. Requests to other routes will be denied. ### Route-Level Restrictions You can also restrict from the route side: 1. Edit a route 2. Under **Allowed Clients**, select specific clients 3. Save Only those clients can access this route. **Best practice:** Use client-level restrictions for third parties, route-level for sensitive endpoints. ## API Key Management ### Viewing Keys Only the first 12 characters (`key_prefix`) are stored and shown in the UI: ```text theme={"dark"} tk_a1b2c3d4_abc1... → tk_a1b2c3d4_a (shown) ``` This prevents accidentally exposing keys. ### Rotating Keys **When to rotate:** * Every 90 days (scheduled maintenance) * Suspected compromise * Employee offboarding * App decommission **How to rotate:** 1. Open client details 2. Click **Rotate Key** 3. Copy new key 4. Update applications 5. Old key stops working immediately **Pro tip:** Generate new key first, update apps, then revoke old key. ### Revoking Access **Temporary disable:** 1. Open client 2. Click **Disable** 3. Re-enable later if needed **Permanent removal:** 1. Click **Delete Client** 2. Confirm 3. Key is revoked immediately ## Rate Limiting Per Client Apply limits per client: 1. Edit route 2. Enable **Rate Limiting** 3. Configure per-client limits: * **Requests:** 1000 * **Window:** 60 seconds * **Burst:** 1200 Prevents abuse and ensures fair usage. ## IP Allowlisting Restrict client to specific IPs: 1. Edit client 2. Add **Allowed IPs**: ```text theme={"dark"} 192.168.1.1 10.0.0.0/8 ``` 3. Save Only requests from those IPs will be accepted. ## Monitoring Client Activity Track client behavior: 1. Go to **Analytics** 2. Filter by client 3. Monitor: * Request volume * Error rates * Response times * Geographic distribution ### Set Up Alerts Get notified of issues: * Error rate > 10% for this client * Unusual request volume * Requests from unexpected locations ## Client Organization ### Naming Convention Use clear, consistent names: ```text theme={"dark"} [env]-[app]-[version] ``` Examples: * `prod-mobile-ios-v2` * `staging-web-app` * `partner-acme-integration` ### Use Descriptions Document important details: * Purpose of this client * Owner/team * Created date * Next rotation date ## Troubleshooting **"Invalid API Key"** * Key was rotated or revoked * Check last 8 characters match * Verify client is enabled **"Access Denied"** * Client lacks permission for this route * Check client's allowed routes * Check route's allowed clients **High Error Rate** * Review client's error logs * Check if backend is having issues * Consider disabling if abusive ## Next Steps * Set up environments for per-env API keys * Learn about secrets for backend credentials * Configure monitoring and alerts *** * **Views**: 1 * **Helpful**: 0 👍 * **Level**: intermediate `clients`, `permissions`, `security`, `api-keys` # mTLS Client Certificates Source: https://docs.knoxcall.com/security/mtls-certificates Secure API communication with mutual TLS (mTLS) client certificates. Store and manage certificates for banking APIs, government services, and enterprise systems. # mTLS Client Certificates Many high-security APIs require **mutual TLS (mTLS)** authentication where both the client and server present certificates to verify identity. KnoxCall supports storing client certificates as secrets and automatically using them for API requests. ## What is mTLS? **Standard TLS (HTTPS):** ```text theme={"dark"} Client → Server: "Hello" Server → Client: "Here's my certificate" Client: Verifies server certificate ✅ Connection established ``` **Mutual TLS (mTLS):** ```text theme={"dark"} Client → Server: "Hello" Server → Client: "Here's my certificate" Client: Verifies server certificate ✅ Server → Client: "Now show me YOUR certificate" Client → Server: "Here's my client certificate" Server: Verifies client certificate ✅ Connection established ``` ### When to Use mTLS **Common use cases:** * 🏦 **Banking APIs** (PSD2, Open Banking) * 🏛️ **Government services** (IRS, HMRC, tax authorities) * 🏢 **Enterprise B2B APIs** (high-security partners) * 💳 **Payment processors** (some card networks) * 🔐 **Zero-trust architectures** (service mesh, internal APIs) **Example providers requiring mTLS:** * Plaid (some endpoints) * TrueLayer (Open Banking) * Yodlee * UK HMRC APIs * German Tax Authority APIs * Some AWS services ## Certificate Formats ### Supported Formats KnoxCall accepts certificates in **PEM format** (text-based): ```pem theme={"dark"} -----BEGIN CERTIFICATE----- MIIDXTCCAkWgAwIBAgIJAKJ5cqZ3xj8VMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX ... (more lines) ... -----END CERTIFICATE----- -----BEGIN PRIVATE KEY----- MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7VJTUt9Us8cKj MzEfYyjiWA4R4/M2bS1+fWIcPm15j9zB/2cdY9SZ4O4nM4F4SfbmA9aM8jl3qv8E ... (more lines) ... -----END PRIVATE KEY----- ``` ### Certificate Components A complete mTLS setup includes: 1. **Client Certificate** (`cert.pem`) * Proves your identity to the server * Signed by a Certificate Authority (CA) * Contains public key 2. **Private Key** (`key.pem`) * Paired with certificate * Kept secret (never share!) * Used to sign requests 3. **Passphrase** (optional) * Protects the private key * Required if key is encrypted * Stored encrypted in KnoxCall 4. **CA Certificate** (optional) * Verifies server's certificate * Usually not needed (uses system trust store) ### Format Conversion If your certificate is in a different format: **From PKCS#12 (.pfx, .p12) to PEM:** ```bash theme={"dark"} # Extract certificate openssl pkcs12 -in cert.pfx -clcerts -nokeys -out cert.pem # Extract private key openssl pkcs12 -in cert.pfx -nocerts -out key.pem # Remove passphrase (optional) openssl rsa -in key.pem -out key-nopass.pem ``` **From DER to PEM:** ```bash theme={"dark"} # Convert certificate openssl x509 -inform DER -in cert.der -out cert.pem # Convert key openssl rsa -inform DER -in key.der -out key.pem ``` **Combine certificate and key:** ```bash theme={"dark"} cat cert.pem key.pem > combined.pem ``` ## Creating a Certificate Secret ### Step 1: Prepare Your Certificate Combine your certificate and private key into a single file: ```bash theme={"dark"} # combined.pem -----BEGIN CERTIFICATE----- ... your certificate ... -----END CERTIFICATE----- -----BEGIN PRIVATE KEY----- ... your private key ... -----END PRIVATE KEY----- ``` **Important:** * Certificate must come first * Private key must come second * Include the `-----BEGIN/END-----` markers * No extra spaces or characters ### Step 2: Create Secret in KnoxCall 1. Navigate to **Secrets** → **Create Secret** 2. Fill in details: ```yaml theme={"dark"} Name: bank_api_certificate Type: Certificate (mTLS) Description: Client certificate for Bank XYZ API ``` 3. **Upload certificate:** * Paste combined PEM content (cert + key) * Or upload `.pem` file 4. **Optional: Add passphrase** * If your private key is encrypted * Enter passphrase (stored encrypted) 5. Click **Create** ### Step 3: Verify Certificate After creation, KnoxCall shows: ```text theme={"dark"} ✅ Certificate validated 📜 Subject: CN=YourCompany, O=YourOrg, C=US 📅 Valid from: 2024-01-01 📅 Expires: 2026-01-01 🔑 Private key: Present 🔐 Passphrase: Yes (encrypted) ``` ## Using Certificates in Routes ### Basic Usage Reference the certificate in your route's advanced configuration: **Route Configuration:** ```json theme={"dark"} { "certificate_secret_id": "{{secret:bank_api_certificate}}" } ``` KnoxCall automatically: * Loads the certificate and private key * Decrypts the passphrase if present * Creates mTLS agent for the request * Presents certificate during TLS handshake ### With OAuth2 Some APIs require **both** OAuth2 and mTLS: ```json theme={"dark"} { "oauth2_secret_id": "{{secret:bank_oauth}}", "certificate_secret_id": "{{secret:bank_api_certificate}}" } ``` Flow: 1. KnoxCall performs OAuth2 token exchange (with mTLS if provider requires it) 2. Subsequent API requests use both OAuth2 token **and** mTLS certificate ### Environment-Specific Certificates Use different certificates for production vs sandbox: **Create certificate secret:** ```text theme={"dark"} Secret: bank_api_certificate Environments: production: cert: -----BEGIN CERTIFICATE----- (prod cert) key: -----BEGIN PRIVATE KEY----- (prod key) passphrase: prod-passphrase sandbox: cert: -----BEGIN CERTIFICATE----- (sandbox cert) key: -----BEGIN PRIVATE KEY----- (sandbox key) passphrase: sandbox-passphrase ``` **Route configuration (same for all environments):** ```json theme={"dark"} { "certificate_secret_id": "{{secret:bank_api_certificate}}" } ``` * Production route → uses production certificate * Sandbox route → uses sandbox certificate * Hard fail if environment missing ## Real-World Examples ### Example 1: Open Banking (TrueLayer) **Requirement:** PSD2-compliant Open Banking requires eIDAS certificates **Setup:** ```bash theme={"dark"} # You receive from CA: - transport_cert.pem (eIDAS transport certificate) - transport_key.pem (private key) # Combine: cat transport_cert.pem transport_key.pem > truelayer_mtls.pem ``` **Create secret:** ```text theme={"dark"} Name: truelayer_mtls_cert Type: Certificate Content: (paste truelayer_mtls.pem) ``` **Route config:** ```json theme={"dark"} { "target_url": "https://api.truelayer.com", "certificate_secret_id": "{{secret:truelayer_mtls_cert}}", "oauth2_secret_id": "{{secret:truelayer_oauth}}" } ``` ### Example 2: Bank XYZ Direct API **Requirement:** Corporate banking API with mTLS only (no OAuth2) **Create secret:** ```text theme={"dark"} Name: bankxyz_client_cert Type: Certificate Content: -----BEGIN CERTIFICATE----- ... (client cert) ... -----END CERTIFICATE----- -----BEGIN ENCRYPTED PRIVATE KEY----- ... (encrypted key) ... -----END ENCRYPTED PRIVATE KEY----- Passphrase: your-key-passphrase ``` **Route config:** ```json theme={"dark"} { "target_url": "https://api.bankxyz.com", "certificate_secret_id": "{{secret:bankxyz_client_cert}}", "headers": { "API-Key": "{{secret:bankxyz_api_key}}" } } ``` ### Example 3: Government Tax API **Requirement:** HMRC API requires mTLS + OAuth2 + API key **Secrets:** ```text theme={"dark"} 1. hmrc_mtls_cert (Certificate type) 2. hmrc_oauth (OAuth2 type) 3. hmrc_vendor_key (String type) ``` **Route config:** ```json theme={"dark"} { "target_url": "https://api.service.hmrc.gov.uk", "certificate_secret_id": "{{secret:hmrc_mtls_cert}}", "oauth2_secret_id": "{{secret:hmrc_oauth}}", "headers": { "Gov-Vendor-Version": "1.0", "Gov-Vendor-Public-Key": "{{secret:hmrc_vendor_key}}" } } ``` ## Security Best Practices ### ✅ Do This 1. **Use passphrase-protected keys** * Extra layer of security * Prevents key theft if certificate leaked 2. **Set certificate expiry reminders** * Certificates expire (typically 1-2 years) * Set calendar reminder 30 days before expiry 3. **Store backups securely** * Keep original `.pfx` or `.p12` file in secure vault * Don't email certificates 4. **Use environment-specific certificates** * Separate certificates for prod/sandbox * Prevents accidental production API usage in dev 5. **Revoke old certificates** * When renewing, revoke old certificate with CA * Remove from KnoxCall after grace period ### ❌ Avoid This 1. **Don't share private keys** * Each system should have unique certificate * Never email or commit to git 2. **Don't use unencrypted keys** * Always use passphrase-protected keys * Even if "just for testing" 3. **Don't ignore expiry warnings** * Expired certificate = API downtime * Plan renewal 60 days ahead 4. **Don't hardcode passphrases** * Use KnoxCall's encrypted passphrase field * Not environment variables ## Certificate Management ### Checking Expiry View expiry in secret detail page: ```text theme={"dark"} Certificate: bank_api_certificate ├─ Subject: CN=YourCompany ├─ Issuer: CN=Certificate Authority ├─ Valid from: 2024-01-01 00:00:00 UTC ├─ Expires: 2026-01-01 00:00:00 UTC ├─ Days remaining: 347 └─ Status: ✅ Valid ``` **Expiry warnings:** * 90 days: 🟡 Warning (plan renewal) * 30 days: 🟠 Urgent (renew now) * Expired: 🔴 Invalid (API calls will fail) ### Renewing Certificates **Process:** 1. **Obtain new certificate** from CA 2. **Create new secret** with different name: ```text theme={"dark"} Old: bank_api_certificate New: bank_api_certificate_2025 ``` 3. **Test in sandbox** first 4. **Update routes** to use new secret: ```json theme={"dark"} { "certificate_secret_id": "{{secret:bank_api_certificate_2025}}" } ``` 5. **Deploy to production** 6. **Monitor** for 48 hours 7. **Delete old secret** after grace period **Zero-downtime renewal:** * Old certificate valid until expiry * New certificate active before expiry * Update routes during low-traffic period ### Rotating Certificates **When to rotate:** * Scheduled renewal (before expiry) * Security incident (suspected compromise) * Compliance requirement (quarterly rotation) * Organization change (company rename) **Rotation checklist:** * [ ] Generate new CSR (Certificate Signing Request) * [ ] Submit to CA * [ ] Receive and validate new certificate * [ ] Upload to KnoxCall as new secret * [ ] Test in sandbox environment * [ ] Update production routes * [ ] Monitor API calls for 48 hours * [ ] Revoke old certificate with CA * [ ] Delete old secret from KnoxCall ## Troubleshooting ### "Certificate validation failed" **Problem:** KnoxCall rejects your certificate during upload **Common causes:** 1. **Incorrect format** ```text theme={"dark"} ❌ Wrong: binary .pfx file ✅ Right: PEM text format ``` 2. **Missing private key** ```text theme={"dark"} ❌ Wrong: only certificate, no key ✅ Right: certificate + private key ``` 3. **Mismatched cert and key** ```text theme={"dark"} Cert and key don't form a pair → Ensure they were generated together ``` 4. **Expired certificate** ```text theme={"dark"} Certificate already expired → Check expiry date before upload ``` **Solution:** ```bash theme={"dark"} # Verify certificate openssl x509 -in cert.pem -text -noout # Verify key openssl rsa -in key.pem -check # Verify they match openssl x509 -noout -modulus -in cert.pem | openssl md5 openssl rsa -noout -modulus -in key.pem | openssl md5 # (hashes should match) ``` ### "mTLS handshake failed" **Problem:** API request fails with TLS error **Possible causes:** 1. **Wrong certificate uploaded** * Production cert used in sandbox (or vice versa) * Solution: Check environment-specific values 2. **Passphrase incorrect** * Private key is encrypted but passphrase wrong * Solution: Re-upload with correct passphrase 3. **Server doesn't trust your certificate** * Certificate not from approved CA * Solution: Obtain certificate from API provider's required CA 4. **Certificate expired** * Check expiry in secret detail page * Solution: Renew certificate 5. **Hostname mismatch** * Certificate's CN doesn't match request hostname * Solution: Obtain cert with correct subject name **Debug steps:** ```bash theme={"dark"} # Test mTLS locally curl --cert cert.pem \ --key key.pem \ https://api.example.com/test # If it works locally but fails in KnoxCall: # → Check passphrase, environment config ``` ### "Cannot decrypt private key" **Problem:** KnoxCall can't load your certificate for requests **Causes:** 1. **Passphrase wrong or missing** * Encrypted key needs passphrase * Solution: Update secret with correct passphrase 2. **Corrupted key** * Key data damaged during upload * Solution: Re-upload certificate 3. **Unsupported encryption** * Very old key encryption algorithms * Solution: Re-encrypt key with modern algorithm: ```bash theme={"dark"} openssl rsa -in old_key.pem -out new_key.pem ``` ### "Private key not found in secret" **Problem:** Certificate uploaded but key missing **Solution:** Ensure your PEM file contains both: ```pem theme={"dark"} -----BEGIN CERTIFICATE----- ... certificate ... -----END CERTIFICATE----- -----BEGIN PRIVATE KEY----- ← Must be present! ... private key ... -----END PRIVATE KEY----- ``` If you have them separately: ```bash theme={"dark"} cat cert.pem key.pem > combined.pem # Then upload combined.pem ``` ## Advanced Configuration ### Custom CA Certificates If the API server uses a custom CA: ```json theme={"dark"} { "certificate_secret_id": "{{secret:client_cert}}", "ca_bundle_secret_id": "{{secret:custom_ca_bundle}}" } ``` Create `custom_ca_bundle` secret (String type): ```pem theme={"dark"} -----BEGIN CERTIFICATE----- ... CA certificate ... -----END CERTIFICATE----- ``` ### SNI (Server Name Indication) For servers requiring specific SNI: ```json theme={"dark"} { "certificate_secret_id": "{{secret:client_cert}}", "tls_servername": "api.specific-domain.com" } ``` ### Disabling Certificate Verification (NOT RECOMMENDED) For testing only (never in production): ```json theme={"dark"} { "certificate_secret_id": "{{secret:client_cert}}", "tls_reject_unauthorized": false } ``` ⚠️ **Warning:** This disables server certificate validation and makes you vulnerable to man-in-the-middle attacks. Only use in isolated test environments. ## Compliance & Auditing ### Audit Trail All certificate create/update/delete operations are captured in the audit log. Query them via: ```bash theme={"dark"} # $TOKEN is an OAuth access token minted via POST /oauth/token — see /api-reference/authentication curl "https://api.knoxcall.com/v1/audit-logs?resource_type=secret" \ -H "Authorization: Bearer $TOKEN" ``` Each entry includes `action` (`create`, `update`, `delete`), `resource_id` (the secret ID), and the source `ip_address`. ### Control mappings The headings below name the requirement each control speaks to. They are mappings, not attestations KnoxCall holds — see the [trust page](https://knoxcall.com/trust) for current certification status. **Maps to PCI DSS requirements:** * ✅ Certificates encrypted at rest * ✅ Private keys never logged * ✅ Access audit trail * ✅ Expiry monitoring **SOC 2:** * ✅ Certificate lifecycle management * ✅ Change tracking * ✅ Access controls * ✅ Secure deletion **PSD2 (Open Banking):** * ✅ eIDAS certificate support * ✅ Qualified signature certificates * ✅ Environment segregation ## API Reference Examples show a short-lived OAuth access token (`kc_live_…`) — mint one via `POST /oauth/token`, or let the [SDK](/sdks/overview) handle authentication for you. Legacy `tk_…` API keys are also accepted on the management API. See [Authentication](/api-reference/authentication). ### Create Certificate Secret ```http theme={"dark"} POST /v1/secrets/certificate Authorization: Bearer kc_live_... Content-Type: application/json { "name": "client_certificate", "certificate_content": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----", "passphrase": "optional-key-passphrase", "certificate_type": "pem" } ``` Field `certificate_type` defaults to `pem`. Accepted values: `pem`, `pfx`, `p12`, `crt`, `cer`, `key`, `pkcs7`, `p7b`, `p7c`. ### Update Certificate Value (Environment-Specific) Update an existing certificate's value for a specific environment: ```http theme={"dark"} PUT /v1/secrets/{id}/value?environment=staging Authorization: Bearer kc_live_... Content-Type: application/json { "value": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" } ``` ### Get Certificate Info (value never returned) ```http theme={"dark"} GET /v1/secrets/{id} Authorization: Bearer kc_live_... Response: { "data": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "CLIENT_CERTIFICATE", "secret_type": "certificate", "base_environment": "production", "collection_id": null, "created_at": "2025-01-15T10:00:00.000Z", "expires_at": null, "environment_count": 1 }, "meta": { "request_id": "..." } } ``` See the [Secrets API reference](/api-reference/secrets) for the full endpoint reference. ## Next Steps Combine OAuth2 with mTLS authentication Learn about secret types and management Use different certificates per environment Best practices for certificate renewal *** * **Level**: Advanced * **Time**: 20 minutes * **Prerequisites**: Understanding of PKI and TLS `mtls`, `certificates`, `security`, `pki`, `banking`, `authentication` # OAuth2 Flow & Token Management Source: https://docs.knoxcall.com/security/oauth2-flow Automated OAuth2 token management with intelligent refresh, rotation, and AES-256 encrypted storage. Never worry about expired tokens again. # OAuth2 Flow & Token Management Automate OAuth2 token management with KnoxCall. Never manually refresh tokens again - KnoxCall handles OAuth2 authentication, token refresh, and rotation automatically. ## The OAuth2 Token Problem Without KnoxCall, managing OAuth2 tokens is painful: ```javascript theme={"dark"} // Your code becomes a mess of token management let accessToken = 'ya29.a0AfH6SMB...'; let refreshToken = '1//0gUc-sQZ...'; let expiresAt = Date.now() + 3600000; async function makeAPICall() { // Check if token expired if (Date.now() >= expiresAt) { // Refresh the token const response = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', body: JSON.stringify({ client_id: 'YOUR_CLIENT_ID', client_secret: 'YOUR_CLIENT_SECRET', refresh_token: refreshToken, grant_type: 'refresh_token', }), }); const data = await response.json(); accessToken = data.access_token; refreshToken = data.refresh_token || refreshToken; expiresAt = Date.now() + (data.expires_in * 1000); // Save to database await saveTokensToDatabase({accessToken, refreshToken, expiresAt}); } // Finally make the actual API call return await fetch('https://www.googleapis.com/drive/v3/files', { headers: { 'Authorization': `Bearer ${accessToken}`, }, }); } ``` **Problems:** * 100+ lines of token management code * Must securely store tokens * Handle refresh failures * Deal with token rotation * Race conditions in concurrent requests ## With KnoxCall ```javascript theme={"dark"} // Your code is clean and simple — the SDK handles auth to KnoxCall too import { KnoxCall } from "@knoxcall/sdk"; const client = new KnoxCall(); // credentials from `knoxcall login` or KNOXCALL_CLIENT_ID / KNOXCALL_CLIENT_SECRET const response = await client.call("google-drive", { path: "/drive/v3/files" }); ``` **KnoxCall automatically:** * ✅ Refreshes tokens before expiry * ✅ Handles token rotation * ✅ Stores tokens encrypted (AES-256) * ✅ Manages concurrent requests * ✅ Retries on auth failures * ✅ Zero downtime, zero code ## OAuth Clients page The **OAuth Clients** page (Settings → Security → OAuth Clients) is the unified surface for managing OAuth 2.1 in KnoxCall. It has three tabs: | Tab | What it shows | | - | - | | **Clients** | Your registered OAuth2 client applications — client ID, allowed scopes, grant types | | **Workload Identity** | Workload Identity Federation bindings — DPoP binding status, last-used timestamp | | **Active Tokens** | Currently live access tokens across all clients — expiry, last-used, DPoP binding status | There are two distinct OAuth flows in KnoxCall: 1. **OAuth2 Secrets** (Secrets page) — store provider tokens (Google, Stripe, etc.) that KnoxCall auto-refreshes and injects into route headers. Use these for outbound API calls through your routes. 2. **OAuth Client Registrations** (OAuth Clients page) — register OAuth clients that grant access to the KnoxCall API itself. Use these for Workload Identity Federation bindings and programmatic API access with DPoP-bound tokens. Use the **Active Tokens** tab as your primary monitoring surface for live token state — you can see which tokens are DPoP-bound, when they expire, and when they were last used, without needing to set up an alert rule. ## Supported OAuth2 Providers KnoxCall supports all OAuth2-compliant providers: ### Pre-Configured Providers * Google Drive * Gmail API * Google Calendar * Google Sheets * Office 365 * OneDrive * Outlook API * Microsoft Graph * Salesforce API * Force.com * Slack API * Slack Webhooks ### Custom OAuth2 Providers Any OAuth2-compliant API: * Stripe * GitHub * Shopify * QuickBooks * Custom APIs ## Setting Up OAuth2 ### Step 1: Register Your Application First, register your application with the OAuth2 provider: 1. Go to [Google Cloud Console](https://console.cloud.google.com/) 2. Create a new project or select existing 3. Enable the API you want to use (Drive, Gmail, etc.) 4. Go to **Credentials** → **Create Credentials** → **OAuth 2.0 Client ID** 5. Application type: **Web application** 6. Authorized redirect URIs: `https://oauth.knoxcall.com/callback` 7. Save **Client ID** and **Client Secret** 1. Go to [Azure Portal](https://portal.azure.com/) 2. Navigate to **Azure Active Directory** → **App registrations** 3. Click **New registration** 4. Name: Your app name 5. Redirect URI: `https://oauth.knoxcall.com/callback` 6. Create and save **Application (client) ID** 7. Go to **Certificates & secrets** → Create client secret 8. Save the secret value For custom OAuth2 providers, you need: * Authorization URL (e.g., `https://example.com/oauth/authorize`) * Token URL (e.g., `https://example.com/oauth/token`) * Client ID * Client Secret * Scopes (permissions needed) ### Step 2: Create OAuth2 Secret in KnoxCall 1. Navigate to **Secrets** → **Add Secret** 2. Configure: **Name:** ```text theme={"dark"} google-drive-oauth ``` **Type:** ```text theme={"dark"} OAuth2 ``` **Provider:** Select from dropdown or choose "Custom" **Client ID:** ```text theme={"dark"} 123456789-abc.apps.googleusercontent.com ``` **Client Secret:** ```text theme={"dark"} GOCSPX-abc123def456 ``` **Scopes:** ```text theme={"dark"} https://www.googleapis.com/auth/drive.readonly https://www.googleapis.com/auth/drive.file ``` **Redirect URI:** ```text theme={"dark"} https://oauth.knoxcall.com/callback ``` 3. Click **Save** ### Step 3: Complete OAuth2 Authorization After creating the secret, you need to authorize it: 1. Click **Authorize** button next to your OAuth2 secret 2. You'll be redirected to the provider's login page 3. Log in and grant permissions 4. You'll be redirected back to KnoxCall 5. Tokens are automatically saved and encrypted ✅ **Done!** KnoxCall now has your OAuth2 tokens. ### Step 4: Use in Routes 1. Create or edit a route 2. Target Base URL: `https://www.googleapis.com` (for Google APIs) 3. In **Header Injection**, add: ```json theme={"dark"} { "Authorization": "Bearer {{secret:google-drive-oauth}}" } ``` 4. Save route Now all requests to this route will include the OAuth2 token automatically! ## How Token Refresh Works KnoxCall uses **intelligent token refresh** that adapts to each provider's token lifetime: ### Dynamic Refresh Threshold Tokens are automatically refreshed when **25% of their lifetime remains** (or 5 minutes before expiry, whichever is greater): ```text theme={"dark"} Short-lived token (20 min): ↓ Refreshes at 15 min (25% = 5 min remaining) ✅ ↓ Long-lived token (24 hours): ↓ Refreshes at 18 hours (25% = 6 hours remaining) ✅ ↓ Token without expiry info: ↓ Refreshes 5 minutes before expiry (fallback) ✅ ``` **Example: 1-hour token lifecycle** ```text theme={"dark"} Token issued: 1:00 PM (expires at 2:00 PM) ↓ Request at 1:40 PM ✅ Uses current token (40 min remaining = 67%) ↓ Request at 1:46 PM 🔄 Refreshes token (14 min remaining = 23%) ↓ New token issued: 1:46 PM (expires at 2:46 PM) ↓ Old token still valid until 2:00 PM (overlap period) ↓ Request succeeds with new token ✅ ``` **Why 75% threshold?** * **Short tokens (\< 20 min)**: Prevents last-second refresh failures * **Long tokens (> 1 hour)**: Avoids unnecessary early refreshes * **Variable tokens**: Adapts to each provider's expiry time * **Network delays**: Buffer time for slow refresh responses **Benefits:** * Adapts to any token lifetime (5 min to 90 days) * Zero downtime during refresh * No race conditions * Automatic retry on failure * Graceful fallback if `expires_in` missing * Token rotation handled transparently ## Token Storage & Security OAuth2 tokens are stored with military-grade security: ### Encryption * **Algorithm**: AES-256-GCM * **Key derivation**: PBKDF2 with 100,000 iterations * **Master key**: Stored in AWS KMS (or your key management) ### Access Control Tokens are: * ❌ Never exposed in logs * ❌ Never returned via API * ❌ Never visible in dashboard * ✅ Only decrypted at request time * ✅ Tenant-isolated * ✅ RBAC-protected ### Token Versioning Every token refresh creates a new version: ```text theme={"dark"} Version 1: Initial authorization Version 2: First refresh (24 hours later) Version 3: Second refresh (48 hours later) ... ``` You can: * View token refresh history * Roll back to previous version (if needed) * See when tokens were last refreshed ## Handling Token Revocation If a user revokes access or tokens expire: ### Automatic Detection KnoxCall detects revoked tokens: ```text theme={"dark"} Request to API → 401 Unauthorized ↓ KnoxCall attempts refresh → 400 Invalid Grant ↓ Token marked as REVOKED ↓ Alert sent to admins ↓ Requests return: "OAuth token revoked, please reauthorize" ``` ### Reauthorization To reauthorize: 1. Go to **Secrets** → Select OAuth2 secret 2. Status shows: **REVOKED** 3. Click **Reauthorize** 4. Complete OAuth2 flow again 5. New tokens saved ## Multi-User OAuth2 For multi-user applications, each user needs their own tokens: ### Approach 1: Per-User Secrets Create a separate secret for each user: ```text theme={"dark"} Secret: user-123-google-oauth Secret: user-456-google-oauth Secret: user-789-google-oauth ``` Use client IDs to route to the right secret: ```bash theme={"dark"} # $TOKEN is a kc_ OAuth access token — see /api-reference/authentication curl https://a1b2c3d4.acme.knoxcall.com/drive/files \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: google-drive" \ -H "x-knoxcall-client-id: user-123" ``` ### Approach 2: Dynamic Secret Selection Use KnoxCall's dynamic secret selection: ```json theme={"dark"} { "Authorization": "Bearer {{secret:oauth-{user_id}}}" } ``` KnoxCall will replace `{user_id}` with the value from request context. ## OAuth2 with Refresh Token Rotation Some providers (like Google) rotate refresh tokens: ```text theme={"dark"} Initial Authorization: access_token: ya29.a0... refresh_token: 1//0gU... expires_in: 3600 First Refresh: access_token: ya29.a0... (new) refresh_token: 1//0hV... (new, old one invalidated!) expires_in: 3600 ``` **KnoxCall handles this automatically:** * Saves new refresh token * Invalidates old refresh token * No manual intervention needed ## Scopes & Permissions Different scopes for different use cases: ### Google Drive (Read-Only) ```text theme={"dark"} https://www.googleapis.com/auth/drive.readonly ``` ### Google Drive (Full Access) ```text theme={"dark"} https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/drive.file ``` ### Gmail (Send Email) ```text theme={"dark"} https://www.googleapis.com/auth/gmail.send ``` ### Microsoft Graph (Calendar) ```text theme={"dark"} Calendars.Read Calendars.ReadWrite ``` Request minimum scopes needed. Users are more likely to grant limited permissions. ## Testing OAuth2 Routes After setting up OAuth2: ```bash theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) curl https://a1b2c3d4.acme.knoxcall.com/drive/v3/files \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: google-drive" ``` You should receive a successful response with your Google Drive files. ## Monitoring OAuth2 Tokens ### Token Health Dashboard Monitor your OAuth2 tokens: * **Last refreshed**: When was the token last refreshed * **Expires at**: When will the token expire * **Refresh attempts**: How many times has refresh been attempted * **Status**: Active, Expiring Soon, Revoked, Failed ### Alerts Set up alerts for OAuth2 issues: 1. Navigate to **Alerts** → **Add Alert** 2. Type: **OAuth2 Token Issues** 3. Configure: ```text theme={"dark"} Alert when: - Token refresh fails 3 times - Token expires in < 24 hours without refresh - Token is revoked by user Notification channels: - Email to: admin@company.com - Slack channel: #oauth-alerts ``` ## Best Practices ### 1. Use Separate Secrets Per Environment ```text theme={"dark"} Production: Secret: google-oauth-prod Staging: Secret: google-oauth-staging Development: Secret: google-oauth-dev ``` ### 2. Request Minimal Scopes Only request permissions you actually need: ❌ **Bad:** ```text theme={"dark"} https://www.googleapis.com/auth/drive (full access) ``` ✅ **Good:** ```text theme={"dark"} https://www.googleapis.com/auth/drive.readonly ``` ### 3. Monitor Token Health Set up monitoring: * Alert on refresh failures * Alert on token expiration * Monitor refresh success rate ### 4. Handle Revocation Gracefully When tokens are revoked: * Show clear error message to users * Provide reauthorization link * Don't expose technical details ### 5. Test Token Refresh Manually trigger refresh to test: 1. Go to secret details 2. Click **Force Refresh** 3. Check logs for success ## Troubleshooting ### "Invalid refresh token" errors **Causes:** * User revoked access * Refresh token expired (rare) * OAuth app was deleted **Fix:** * Reauthorize the secret * Check OAuth app is still active * Verify redirect URI matches ### Token refresh fails silently **Check:** * Alert configuration * Error logs in KnoxCall * OAuth provider's API status ### Concurrent request issues **Solution:** KnoxCall handles this automatically with request queuing. If you see issues, contact support. ### Wrong scope errors ```http theme={"dark"} HTTP/1.1 403 Forbidden { "error": "insufficient_scope" } ``` **Fix:** 1. Edit OAuth2 secret 2. Add required scopes 3. Reauthorize ## Next Steps Learn about managing secrets Add signature verification Advanced route configuration Monitor API usage and errors *** * **Level**: intermediate * **Time**: 20 minutes `oauth2`, `authentication`, `tokens`, `google`, `microsoft` # Request Signing Source: https://docs.knoxcall.com/security/request-signing Secure your APIs with HMAC-SHA256 cryptographic signatures. Prevent tampering, replay attacks, and man-in-the-middle attacks. # Request Signing Add an extra layer of security to your routes with cryptographic request signing. Verify that requests haven't been tampered with and prevent replay attacks. ## What is Request Signing? Request signing uses **HMAC-SHA256** (Hash-based Message Authentication Code) to create a cryptographic signature of each request. This signature proves: * ✅ **Authenticity**: Request came from a trusted source * ✅ **Integrity**: Request wasn't modified in transit * ✅ **Non-repudiation**: Sender cannot deny sending the request ## Why Use Request Signing? ### Without Request Signing ```text theme={"dark"} Attacker intercepts request ↓ Modifies payload amount: $10 → $1,000 ↓ Forwards modified request ↓ Your backend processes fraudulent request ❌ ``` ### With Request Signing ```text theme={"dark"} Attacker intercepts request ↓ Modifies payload amount: $10 → $1,000 ↓ Signature verification fails ❌ ↓ Request rejected before reaching backend ✅ ``` ## How It Works ### Signature Creation (Client Side) 1. **Get current timestamp (Unix seconds):** ```text theme={"dark"} timestamp = 1640000000 ``` 2. **Create payload to sign:** ```text theme={"dark"} payload = "timestamp.requestBody" Example: "1640000000.{\"orderId\":\"123\",\"amount\":99.99}" ``` 3. **Create HMAC-SHA256 signature:** ```text theme={"dark"} signature = HMAC-SHA256(apiKey, payload) ``` 4. **Format signature header:** ```text theme={"dark"} X-KnoxCall-Signature: t=1640000000,v1=abc123def456... ``` **Format**: `t=,v1=` * `t=`: Unix timestamp when request was created * `v1=`: HMAC-SHA256 signature in hexadecimal 5. **Include in request headers:** ```http theme={"dark"} X-KnoxCall-Key: your-api-key X-KnoxCall-Signature: t=1640000000,v1=abc123def456... ``` ### Signature Verification (KnoxCall) 1. Extract signature and timestamp from headers 2. Recreate the message from request components 3. Compute expected signature using shared secret 4. Compare signatures: * **Match** → Request is valid ✅ * **Mismatch** → Request is rejected ❌ ## Setting Up Request Signing ### Step 1: Enable on Route 1. Navigate to **Routes** → Select your route 2. Scroll to **Security** section 3. Toggle **Require Signature** to ON 4. Configure settings: **Signature Header:** ```text theme={"dark"} X-Signature ``` (Default, can be customized) **Timestamp Header:** ```text theme={"dark"} X-Timestamp ``` (Default, can be customized) **Signature Algorithm:** ```text theme={"dark"} HMAC-SHA256 ``` (Most secure and widely supported) **Timestamp Tolerance:** ```text theme={"dark"} 300 seconds (5 minutes) ``` Allows clock skew between client and server. 5. Click **Save** ### Step 2: Create Signing Secret 1. Navigate to **Secrets** → **Add Secret** 2. Configure: **Name:** ```text theme={"dark"} my-signing-secret ``` **Type:** ```text theme={"dark"} Static Secret ``` **Value:** ```text theme={"dark"} [Generate a strong random key] ``` **Recommended:** Use a cryptographically secure random string: ```bash theme={"dark"} openssl rand -hex 32 ``` 3. Click **Save** ### Step 3: Assign Secret to Client 1. Navigate to **Clients** → Select your client 2. Go to **Signing Configuration** tab 3. Select the signing secret 4. Click **Save** Now this client must sign all requests to protected routes. ## Client Implementation ## Which secret signs the request The signature is keyed with **the secret this request authenticated with** - never with a header you choose. Which secret that is depends on your credential type: | You authenticate with | Sign with | Scheme | | - | - | - | | `x-knoxcall-key: tk_...` / `AKE...` (API key) | that same API key | `v1` or `v2` | | `Authorization: Bearer kc_...` (OAuth 2.1) | that access token | `v2` only | | `x-knoxcall-agent-id` + `x-knoxcall-agent-token` (agent intercept) | the agent session key for the current window hour, from your session bundle | `v2` only | `v1` signs `timestamp.body`, so a captured signature can be replayed against any method and path inside the tolerance window. `v2` signs `timestamp.METHOD.path.body`, which closes that. `v1` is kept only for existing API-key signers; new integrations should send `v2`. Sending `x-knoxcall-key` alongside an OAuth token does **not** make that key the signing secret. On the OAuth and agent paths the header is never read, and a signature keyed with it is rejected. The examples below use the API-key path, where the key you send in `x-knoxcall-key` is also the HMAC signing secret. To sign with an OAuth token instead, substitute the `kc_...` token for `apiKey` in any snippet and emit `v2=` in place of `v1=`, signing `${timestamp}.${METHOD}.${path}.${body}`. ### JavaScript/Node.js ```javascript theme={"dark"} const crypto = require('crypto'); function signRequest(body, apiKey) { // 1. Get current timestamp (Unix seconds) const timestamp = Math.floor(Date.now() / 1000); // 2. Stringify body (use empty string for GET requests) const bodyStr = body ? JSON.stringify(body) : ''; // 3. Create message to sign: "timestamp.body" const payload = `${timestamp}.${bodyStr}`; // 4. Create HMAC-SHA256 signature const signature = crypto .createHmac('sha256', apiKey) .update(payload) .digest('hex'); // 5. Format signature header: "t=timestamp,v1=signature" const signatureHeader = `t=${timestamp},v1=${signature}`; return { signatureHeader, timestamp }; } // Usage const body = { orderId: '123', amount: 99.99 }; const apiKey = 'your-tenant-api-key'; // Use your KnoxCall API key as signing secret const { signatureHeader } = signRequest(body, apiKey); // Make request with signature fetch('https://your-subdomain.knoxcall.com/api/orders', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-knoxcall-route': 'my-route', 'x-knoxcall-key': apiKey, 'x-knoxcall-signature': signatureHeader, }, body: JSON.stringify(body), }); ``` ### Python ```python theme={"dark"} import hmac import hashlib import time import json import requests def sign_request(body, api_key): # 1. Get current timestamp timestamp = int(time.time()) # 2. Stringify body (empty string for GET requests) body_str = json.dumps(body) if body else '' # 3. Create payload: "timestamp.body" payload = f"{timestamp}.{body_str}" # 4. Create HMAC-SHA256 signature signature = hmac.new( api_key.encode(), payload.encode(), hashlib.sha256 ).hexdigest() # 5. Format signature header: "t=timestamp,v1=signature" signature_header = f"t={timestamp},v1={signature}" return signature_header # Usage body = {'orderId': '123', 'amount': 99.99} api_key = 'your-tenant-api-key' signature_header = sign_request(body, api_key) # Make request with signature response = requests.post( 'https://your-subdomain.knoxcall.com/api/orders', headers={ 'Content-Type': 'application/json', 'x-knoxcall-route': 'my-route', 'x-knoxcall-key': api_key, 'x-knoxcall-signature': signature_header, }, json=body ) ``` ### Go ```go theme={"dark"} package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "time" ) func signRequest(body interface{}, apiKey string) string { // 1. Get current timestamp timestamp := time.Now().Unix() // 2. Stringify body (empty string for GET requests) var bodyStr string if body != nil { bodyJSON, _ := json.Marshal(body) bodyStr = string(bodyJSON) } // 3. Create payload: "timestamp.body" payload := fmt.Sprintf("%d.%s", timestamp, bodyStr) // 4. Create HMAC-SHA256 signature h := hmac.New(sha256.New, []byte(apiKey)) h.Write([]byte(payload)) signature := hex.EncodeToString(h.Sum(nil)) // 5. Format signature header: "t=timestamp,v1=signature" signatureHeader := fmt.Sprintf("t=%d,v1=%s", timestamp, signature) return signatureHeader } // Usage func main() { body := map[string]interface{}{ "orderId": "123", "amount": 99.99, } apiKey := "your-tenant-api-key" signatureHeader := signRequest(body, apiKey) // Include in request headers: // x-knoxcall-key: apiKey // x-knoxcall-signature: signatureHeader } ``` ### PHP ```php theme={"dark"} '123', 'amount' => 99.99]; $apiKey = 'your-tenant-api-key'; $signatureHeader = signRequest($body, $apiKey); // Make request with signature $ch = curl_init('https://your-subdomain.knoxcall.com/api/orders'); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json', 'x-knoxcall-route: my-route', 'x-knoxcall-key: ' . $apiKey, 'x-knoxcall-signature: ' . $signatureHeader, ]); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); $response = curl_exec($ch); curl_close($ch); ?> ``` ## Signature Verification Process KnoxCall performs the following checks: ### 1. Timestamp Validation ```text theme={"dark"} Current Time: 15:30:00 Request Timestamp: 15:28:30 Tolerance: 5 minutes Time difference: 1.5 minutes ✅ VALID ``` **Rejected if:** * Timestamp is more than 5 minutes old * Timestamp is in the future (clock skew) ### 2. Signature Recreation ```text theme={"dark"} Extract from request: - Signature header: t=1640000000,v1=abc123def456... - Body: {"orderId":"123","amount":99.99} Parse signature header: - Timestamp (t): 1640000000 - Signature (v1): abc123def456... Recreate payload: payload = "1640000000.{\"orderId\":\"123\",\"amount\":99.99}" Compute expected signature: HMAC-SHA256(apiKey, payload) = abc123def456... ``` ### 3. Signature Comparison ```text theme={"dark"} Request Signature: abc123def456... Expected Signature: abc123def456... Match? ✅ Request is authentic ``` ## Preventing Replay Attacks Request signing combined with timestamps prevents replay attacks: ```text theme={"dark"} Attacker intercepts valid request ↓ Timestamp: 15:00:00 Signature: abc123... (valid at 15:00:00) ↓ Attacker replays request at 15:10:00 ↓ Timestamp check fails (> 5 minutes old) ↓ Request rejected ❌ ``` ## Replay Attack Window Request signing uses timestamp validation to prevent replays. A request signed at time T is accepted for `signature_tolerance_sec` seconds (default 300 / 5 minutes). After that window, the same signed request is rejected. ```text theme={"dark"} Attacker intercepts valid request at T=0 Attacker replays it at T=360 (6 minutes later) → Rejected: signature expired (age: 360s, tolerance: 300s) ``` Within the tolerance window, a replay technically passes signature validation — because KnoxCall does not track request nonces. For endpoints where this matters (e.g. idempotency-critical mutations), enforce your own deduplication at the application layer using a request ID or idempotency key. ## Error Responses ### Invalid Signature ```http theme={"dark"} HTTP/1.1 401 Unauthorized Content-Type: application/json { "error": "Unauthorized" } ``` ### Timestamp Too Old ```http theme={"dark"} HTTP/1.1 401 Unauthorized Content-Type: application/json { "error": "Unauthorized" } ``` ### Missing Headers ```http theme={"dark"} HTTP/1.1 400 Bad Request Content-Type: application/json { "error": "Missing signature headers", "message": "x-signature and x-timestamp headers are required", "required_headers": ["x-signature", "x-timestamp"] } ``` ## Best Practices ### 1. Use Strong Secrets Generate cryptographically secure signing secrets: ```bash theme={"dark"} # Good: 64 hex characters (256 bits) openssl rand -hex 32 ``` ❌ **Bad:** * `password123` * `my-secret-key` * Short or predictable strings ### 2. Rotate Secrets Regularly Rotate signing secrets every 90 days: 1. Create new secret 2. Update clients with new secret (gracefully) 3. Support both old and new secrets for 24 hours 4. Remove old secret ### 3. Use HTTPS Always Request signing protects integrity, but not confidentiality: ```text theme={"dark"} HTTPS encrypts the entire request (including signature) Signature prevents tampering after decryption ``` Always use HTTPS + request signing together. ### 4. Include All Relevant Data Sign everything that matters: ```javascript theme={"dark"} // Good: Includes body and important headers const message = `${timestamp}.${method}.${path}.${body}.${contentType}`; // Bad: Only timestamp const message = `${timestamp}`; ``` ### 5. Handle Clock Skew Allow 5-minute tolerance for client clock differences: ```text theme={"dark"} Server Time: 15:30:00 Client Time: 15:29:00 (1 minute behind) Still accepted ✅ ``` ## Use Cases ### Payment Processing ```text theme={"dark"} Route: /api/payments Require Signature: ON Reason: Prevent payment amount tampering ``` ### Administrative Actions ```text theme={"dark"} Route: /api/admin/* Require Signature: ON Reason: Ensure admin requests are authentic ``` ### Webhook Validation ```text theme={"dark"} Route: /webhooks/external Require Signature: ON Reason: Verify webhooks came from real source ``` ### High-Value Transactions ```text theme={"dark"} Routes: /api/transfers, /api/withdrawals Require Signature: ON Reason: Prevent unauthorized financial transactions ``` ## Troubleshooting ### "Invalid signature" errors **Check:** * ✓ Using the correct signing secret * ✓ Message format matches exactly (order matters) * ✓ Body is JSON-stringified identically * ✓ No extra whitespace in headers **Debug:** Log the message before signing: ```javascript theme={"dark"} console.log('Message to sign:', message); console.log('Signature:', signature); ``` ### "Timestamp expired" errors **Causes:** * Client clock is out of sync * Request took too long to reach KnoxCall * Network latency **Fix:** * Sync client clock with NTP * Increase timestamp tolerance (temporarily for testing) * Reduce request queuing on client side ### Signature works in test but not production **Check:** * ✓ Using production signing secret (not test secret) * ✓ Correct route configuration in production * ✓ Client is assigned to production environment ## Next Steps Add rate limits for additional protection Learn about managing signing secrets Configure client-level security Set up OAuth2 authentication *** * **Level**: advanced * **Time**: 20 minutes `security`, `hmac`, `signatures`, `authentication`, `integrity` # Rotation Strategies Source: https://docs.knoxcall.com/security/rotation-strategies Learn how to rotate API keys, secrets, and routes for security. Zero-downtime rotation patterns included. # Rotation Strategies Regularly rotating credentials and configurations is critical for security. KnoxCall provides tools to rotate everything with minimal downtime. ## What Can Be Rotated? KnoxCall supports rotating: * **🔑 API Keys**: Create new keys, revoke old ones * **🔐 Secrets**: Add new versions of encrypted credentials * **🛣️ Routes**: Duplicate route configurations * **🌍 Environments**: Clone environment overrides Each rotation strategy has different impacts and use cases. ## Why Rotate? ### Security Best Practices **Rotate when:** * ✅ Credentials are compromised or suspected compromise * ✅ Employee leaves or loses access * ✅ Compliance requirements (e.g., 90-day rotation) * ✅ Moving from development to production * ✅ Deprecating old integration **Benefits:** * 🔒 Limit window of exposure from leaked credentials * 📊 Audit trail of when credentials changed * 🎯 Zero-trust architecture (regular rotation = no long-lived secrets) * 🛡️ Compliance with security frameworks (SOC 2, ISO 27001) ## Rotation Strategy 1: API Keys ### What It Is KnoxCall API keys authenticate requests: ```text theme={"dark"} x-knoxcall-key: tk_abc123xyz789... ``` **Rotating API keys** = Create new key, revoke old key **Prefer skipping the rotation treadmill entirely:** OAuth 2.1 access tokens (`kc_…`) expire after one hour and are minted on demand — there is no long-lived credential to rotate. Register an OAuth client (or use workload identity federation for zero stored secrets) and let the [SDK](/sdks/overview) mint and refresh tokens for you — see [Authentication](/api-reference/authentication). The guidance below applies to long-lived API keys, which remain fully supported. ### When to Rotate **Rotate API keys when:** * ✅ Every 90 days (security best practice) * ✅ Key exposed (logs, screenshots, public repo) * ✅ Employee departure * ✅ Service migration (dev → prod) * ✅ Suspect unauthorized access **Benefits:** * Limit blast radius of compromised keys * Clear audit trail of which key made which requests * Different keys per environment (dev/staging/prod) ### How to Rotate API Keys #### Method 1: Create-Before-Revoke (Zero Downtime) **Step 1: Create New Key** 1. Navigate to **Settings** → **Tenant** tab 2. Scroll to **API Keys** section 3. Click **Create API Key** 4. Name it (e.g., "Production Key 2025 Q1") 5. **Copy the key immediately** (you'll only see it once!) Example key: `tk_def456ghi789jkl012mno345pqr567stu890` **Step 2: Update One Client** Deploy new key to **one client** first (canary): ```javascript theme={"dark"} // Old key const API_KEY = "tk_abc123xyz789..."; // New key const API_KEY = "tk_def456ghi789..."; ``` Restart that client and monitor logs. **Step 3: Verify** Check **Logs** page - requests should show new key ID: ```text theme={"dark"} Key ID: key_def456... ✓ ``` If errors occur, revert and investigate. **Step 4: Update Remaining Clients** Roll out new key to all clients: * Production servers * Staging servers * Development environments * CI/CD pipelines **Step 5: Monitor Grace Period** Wait 24-48 hours. Check logs to ensure old key isn't being used: ```sql theme={"dark"} -- Check if old key still in use SELECT COUNT(*) FROM api_logs WHERE key_id = 'key_abc123...' AND timestamp > NOW() - INTERVAL '1 day'; ``` If count is 0, old key is unused. **Step 6: Revoke Old Key** 1. Navigate to **Settings** → **Tenant** tab → **API Keys** 2. Find old key: `tk_abc123...` 3. Click **Revoke** 4. Confirm Old key stops working immediately. **Pro tip**: Create multiple keys for different environments. If one is compromised, you only revoke that one instead of breaking all environments. #### Method 2: Blue-Green Key Rotation Use two keys in rotation: **Setup:** * **Blue Key**: `tk_blue_abc123...` (currently active) * **Green Key**: `tk_green_def456...` (standby) **Rotation cycle:** ```text theme={"dark"} Week 1: Blue active, create new Green Week 2: Switch to Green, revoke old Blue Week 3: Green active, create new Blue Week 4: Switch to Blue, revoke old Green ...cycle continues ``` **Benefits:** * Always have backup key ready * Predictable rotation schedule * Easy to automate **Implementation:** ```javascript theme={"dark"} const KEYS = { blue: process.env.KNOXCALL_BLUE_KEY, green: process.env.KNOXCALL_GREEN_KEY, active: process.env.KNOXCALL_ACTIVE_KEY // "blue" or "green" }; const currentKey = KEYS[KEYS.active]; ``` ### API Key Rotation Schedule Recommended rotation frequencies: | Environment | Rotation Frequency | Reason | | - | - | - | | **Production** | Every 90 days | Compliance, security | | **Staging** | Every 180 days | Lower risk, less traffic | | **Development** | Annually | Minimal risk | | **CI/CD** | Every 90 days | Automated, can't leak via human | | **Third-party integrations** | When integration changes | Control scope | ## Rotation Strategy 2: Secrets ### What It Is Secrets (API keys, passwords, tokens) stored in KnoxCall support **versioning**. **Example:** ```text theme={"dark"} Secret: stripe-api-key - v1: sk_live_old123... (created Jan 1) - v2: sk_live_new789... (created Mar 15) ← active version ``` **Rotating secrets** = Add new version, which becomes active immediately ### When to Rotate **Rotate secrets when:** * ✅ Secret exposed or leaked * ✅ Third-party service rotates their keys (e.g., Stripe rotates automatically) * ✅ Regular compliance schedule (90 days) * ✅ Employee with access leaves * ✅ Moving from test to production **Benefits:** * Zero-downtime rotation (new version immediately active) * Version history for rollback * Audit trail of when secrets changed ### How to Rotate Secrets #### Step 1: Generate New Secret (Third-Party) First, generate the new credential in the external service: **Stripe example:** 1. Log in to Stripe Dashboard 2. Navigate to **Developers** → **API keys** 3. Click **Create secret key** 4. Name it: "KnoxCall Production Key 2025" 5. **Copy the key**: `sk_live_EXAMPLE_new789xyz456` **Do NOT revoke old key yet!** #### Step 2: Add New Version in KnoxCall 1. Navigate to **Secrets** 2. Click the secret (e.g., "stripe-prod-key") 3. Click **Add New Version** 4. Paste new secret value: ```text theme={"dark"} sk_live_EXAMPLE_new789xyz456 ``` 5. Click **Save** **What happens:** * New version (v2) becomes active immediately * All routes using `{{secret:stripe-prod-key}}` now use v2 * Old version (v1) preserved for rollback #### Step 3: Test Immediately test routes that use this secret: 1. Navigate to **Routes** → Find routes using this secret 2. Click **Test Route** 3. Send test request 4. Verify backend receives new secret (check backend logs) If errors occur: 1. Navigate to **Secrets** → secret detail 2. Click **Rollback to v1** 3. Investigate issue #### Step 4: Monitor Watch logs for 15-30 minutes: ```text theme={"dark"} ✓ Route: stripe-payments - Success (200) ✓ Route: stripe-refunds - Success (200) ✓ Route: stripe-customers - Success (200) ``` If all successful, rotation is complete! #### Step 5: Revoke Old Secret (Third-Party) After 24-48 hours of successful usage: 1. Log in to third-party service (Stripe) 2. Revoke old key: `sk_live_old123...` 3. Confirm Old key no longer works, even if someone has it. **Grace period**: Keep old secret active in third-party service for 24-48 hours. This gives time to catch any missed integrations before revoking. ### Secret Rotation Patterns #### Pattern 1: Immediate Rotation (Security Incident) **Scenario:** Secret leaked publicly (e.g., committed to GitHub) **Steps:** 1. Add new version in KnoxCall (0-downtime) 2. Test immediately (\< 5 minutes) 3. Revoke old secret in third-party **immediately** (don't wait) **Downtime:** \~5 minutes (time to test) #### Pattern 2: Scheduled Rotation (Compliance) **Scenario:** Quarterly 90-day rotation policy **Steps:** 1. Schedule rotation date (e.g., first Monday of quarter) 2. Generate new credentials 1 day before 3. Add new version in KnoxCall 4. Monitor for 48 hours 5. Revoke old credentials **Downtime:** 0 minutes #### Pattern 3: Rolling Window Rotation **Scenario:** Multiple secrets to rotate **Strategy:** Stagger rotations over days/weeks **Example schedule:** ```text theme={"dark"} Week 1: Rotate Stripe key Week 2: Rotate SendGrid key Week 3: Rotate Twilio key Week 4: Rotate database password ``` **Benefits:** * Spread risk (don't rotate everything at once) * Easier to identify issues * Less overwhelming for team ### Secret Rotation Checklist Before rotating a secret: * [ ] **Identify all routes** using the secret * [ ] **Generate new credential** in third-party service * [ ] **Add new version** in KnoxCall * [ ] **Test all affected routes** (Route Tester) * [ ] **Monitor logs** for 15-30 minutes * [ ] **Document rotation** (when, why, who) * [ ] **Schedule revocation** of old secret (24-48 hours) * [ ] **Verify old secret revoked** in third-party service ## Rotation Strategy 3: Routes ### What It Is **Duplicating a route** creates a copy with the same configuration. **Use cases:** * 🔄 Create v2 of API while keeping v1 alive * 🌍 Duplicate production route for staging * 🧪 Test configuration changes without affecting live route * 📦 Clone route to different tenant ### How to Duplicate Routes #### Method: Duplicate via UI Currently, routes can be duplicated by creating a new route and manually copying configuration. **Steps:** 1. Navigate to **Routes** → Click route to duplicate 2. Note all configuration: * Target URL * Headers * Body injection * Secrets used * Assigned clients 3. Click **Create Route** 4. Enter new route name (e.g., `stripe-payments-v2`) 5. Fill in same configuration 6. Save #### Method: Duplicate via API Coming soon: API endpoint for route duplication. ### Route Migration Pattern **Scenario:** Migrating API from v1 to v2 **Steps:** **1. Duplicate route:** ```text theme={"dark"} Old: stripe-payments (points to api.stripe.com/v1) New: stripe-payments-v2 (points to api.stripe.com/v2) ``` **2. Test new route:** * Assign test clients only * Send test requests * Verify responses **3. Gradual migration:** ```javascript theme={"dark"} // Use route based on client const route = client.useV2 ? "stripe-payments-v2" : "stripe-payments"; fetch(`https://x9y8z7w6.DunderMifflin.knoxcall.com/charges`, { headers: { "x-knoxcall-route": route } }); ``` **4. Monitor both routes:** * V1 requests declining * V2 requests increasing **5. Deprecate v1:** When v1 requests = 0 for 7 days: * Disable v1 route * Monitor for errors * Delete v1 route after 30 days ## Rotation Strategy 4: Environments ### What It Is **Environment duplication** copies environment override configuration. **Use cases:** * 📋 Clone production config to create new environment * 🧪 Test environment changes before deploying * 🌍 Create region-specific environments (US, EU) ### How to Duplicate Environments **Step 1: Navigate to Route** 1. Go to **Routes** → Click route 2. Switch to **Environments** tab **Step 2: Duplicate Environment** 1. Select environment to copy (e.g., "production") 2. Click **⋯** (more options) 3. Select **Duplicate Environment** 4. Enter new environment name: `production-eu` 5. Click **Duplicate** **What's copied:** * ✅ Target URL override * ✅ Header injection overrides * ✅ Body injection overrides * ✅ Rate limit overrides * ✅ Signature settings overrides **What's NOT copied:** * ❌ Assigned clients (you assign separately) **Step 3: Modify Duplicated Environment** Update the new environment for your needs: ```text theme={"dark"} production-eu: - Target URL: https://api-eu.service.com (changed) - Headers: (copied from production) - Secrets: (same as production) ``` **Step 4: Assign Clients** Assign clients to new environment: 1. Go to **Clients** tab 2. Select `production-eu` 3. Assign appropriate clients (e.g., EU servers) ### Environment Migration Pattern **Scenario:** Migrating to new backend infrastructure **Steps:** **1. Duplicate environment:** ```text theme={"dark"} production → production-new-infra ``` **2. Update target URL:** ```text theme={"dark"} Old: https://legacy-api.example.com New: https://new-infra-api.example.com ``` **3. Assign canary client:** Assign 1 client to test: ```text theme={"dark"} Client: production-server-01 Environment: production-new-infra ``` **4. Send requests with environment header:** ```bash theme={"dark"} # $TOKEN is a kc_ OAuth access token — see /api-reference/authentication curl -X GET "https://x9y8z7w6.DunderMifflin.knoxcall.com/api/users" \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: users" \ -H "x-knoxcall-environment: production-new-infra" ``` **5. Monitor:** * Check logs for errors * Compare response times * Verify data integrity **6. Gradual rollout:** Assign more clients over days/weeks: ```text theme={"dark"} Week 1: 1 client (1%) Week 2: 10 clients (10%) Week 3: 50 clients (50%) Week 4: All clients (100%) ``` **7. Make default:** Once all clients migrated: 1. Update base route configuration to point to new infra 2. Delete `production-new-infra` environment (no longer needed) ## Best Practices ### 1. Document Rotation Events **Keep a rotation log:** | Date | Asset | Old Value | New Value | Reason | By | | - | - | - | - | - | - | | 2025-01-15 | stripe-prod-key | sk\_live\_old... | sk\_live\_new\... | Scheduled 90-day rotation | John | | 2025-02-01 | API Key (prod) | tk\_abc... | tk\_def... | Key leaked in logs | Sarah | **Benefits:** * Audit compliance * Troubleshooting * Team awareness ### 2. Test Before Revoking **Always:** ✅ Add new credential ✅ Test thoroughly ✅ Monitor for 24-48 hours ✅ Then revoke old credential **Never:** ❌ Revoke old credential immediately ❌ Skip testing ❌ Assume it works ### 3. Use Grace Periods After adding new credential, keep old one active: * **Low risk**: 24 hours * **Medium risk**: 48 hours * **High risk (leaked)**: 1 hour minimum Allows time to catch missed integrations. ### 4. Automate Where Possible **Automatable:** * Scheduled secret rotation (every 90 days) * API key rotation (every 90 days) * Monitoring for rotation events **Not automatable (require human judgment):** * Route duplication (business logic) ### 5. Use Separate Keys Per Environment **Good:** ```text theme={"dark"} API Keys: - Production Key A (prod servers) - Production Key B (prod CI/CD) - Staging Key (staging servers) - Development Key (dev laptops) ``` **Bad:** ```text theme={"dark"} API Keys: - One key for everything ❌ ``` If one key is compromised, you only revoke that one. ## Rotation Frequency Recommendations | Asset | Frequency | Notes | | - | - | - | | **API Keys (prod)** | Every 90 days | Compliance standard | | **API Keys (staging)** | Every 180 days | Lower risk | | **Secrets (payment APIs)** | Every 90 days | High sensitivity | | **Secrets (internal)** | Every 180 days | Lower sensitivity | | **Routes** | As needed | For versioning | | **Environments** | As needed | For infrastructure changes | ## Troubleshooting ### Issue: "Rotation caused downtime" **Cause:** Revoked old credential before new one was deployed everywhere **Prevention:** 1. Always deploy new credential first 2. Monitor for 24-48 hours 3. Then revoke old ### Issue: "Can't find which routes use a secret" **Solution:** 1. Navigate to **Secrets** → Click secret 2. View **Used By Routes** section 3. Lists all routes using this secret ### Issue: "Rotated secret but old one still being used" **Cause:** Route has method-specific config using old secret **Check:** 1. Route → **Method Configs** tab 2. Check each HTTP method 3. Some methods might override with old secret **Fix:** Update method-specific configs ### Issue: "Rolled subdomain but clients still using old URL" **Cause:** Hardcoded URLs, not environment variables **Prevention:** ```javascript theme={"dark"} // ❌ Don't hardcode const url = "https://a1b2c3d4.DunderMifflin.knoxcall.com"; // ✅ Use environment variable const url = process.env.KNOXCALL_BASE_URL; ``` ## Related Features * **Secret Versioning**: Manage multiple versions of secrets * **API Key Management**: Create and revoke keys * **Environment tab**: Per-environment configuration * **Audit Logs**: Track all configuration changes ## Next Steps Learn about secret versioning Track rotation events Manage authentication keys Configure environment overrides *** * **Level**: intermediate to advanced * **Time**: 20 minutes `security`, `rotation`, `compliance`, `credentials`, `zero-downtime` # Secrets Quick Reference Source: https://docs.knoxcall.com/security/secrets-quick-reference Quick reference guide for creating and using secrets. Fast 3-step guide for API keys and credentials. # Secrets Quick Reference Store sensitive credentials securely and inject them into requests without exposing them to clients. ## What is a Secret? A **secret** is an encrypted credential (like an API key or password) that KnoxCall injects into your backend requests server-side. **Why use secrets?** * ✅ Credentials never exposed to clients * ✅ Centrally managed and encrypted * ✅ Easy to rotate without code changes ## Real-World Example **Without secrets (❌ Insecure):** ```javascript theme={"dark"} // Frontend code - API key exposed! const stripeKey = "sk_live_abc123"; fetch("https://api.stripe.com/charges", { headers: { "Authorization": `Bearer ${stripeKey}` } }); ``` **With secrets (✅ Secure):** ```javascript theme={"dark"} // Frontend code - Safe! // Uses a short-lived KnoxCall OAuth token (kc_…), never the Stripe key fetch("https://your-app.knoxcall.com/api/payments", { headers: { "Authorization": "Bearer KNOXCALL_OAUTH_TOKEN", "x-knoxcall-route": "payments" } }); // KnoxCall injects Stripe key server-side ``` ## Create a Secret ### Step 1: Navigate to Secrets 1. Click **Resources** in sidebar 2. Select **Secrets** 3. Click **Add Secret** ### Step 2: Fill in Details **Secret Name:** ```text theme={"dark"} stripe_api_key ``` *Use lowercase with underscores* **Secret Value:** ```text theme={"dark"} sk_live_abc123... ``` *Paste your actual API key* **Description:** *(optional)* ```text theme={"dark"} Stripe production API key - rotate every 90 days ``` ### Step 3: Save Click **Save**. The value is encrypted immediately and you'll never see it again! ## Use the Secret in a Route ### Inject as Header 1. Edit your route 2. Scroll to **Header Injection** 3. Add: ```json theme={"dark"} { "Authorization": "Bearer {{secret:stripe_api_key}}" } ``` *Use `{{secret:secret_name}}` syntax* ### Inject in Body For POST/PUT requests: ```json theme={"dark"} { "api_key": "{{secret:stripe_api_key}}", "other_field": "value" } ``` ## Test It Make a request through your route: ```bash theme={"dark"} # Mint a 1-hour OAuth token (see /api-reference/authentication) TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) curl https://your-domain.knoxcall.com/api/payments \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: payments" ``` KnoxCall automatically adds: ```text theme={"dark"} Authorization: Bearer sk_live_abc123... ``` Your Stripe key is injected but never exposed to the client! ## Security Best Practices ✅ **Do:** * Use descriptive names: `stripe_production_key` * Rotate every 90 days * Delete unused secrets * Use different secrets for dev/staging/prod ❌ **Don't:** * Use generic names: `secret1`, `key` * Reuse secrets across services * Share secrets via email/chat * Use production secrets in development ## Common Use Cases **Third-party APIs:** ```text theme={"dark"} stripe_api_key sendgrid_api_key twilio_auth_token ``` **Databases:** ```text theme={"dark"} database_password redis_url postgres_connection_string ``` **OAuth:** ```text theme={"dark"} google_oauth_token github_access_token ``` ## Next Steps * Learn about secret rotation for security * Set up environment-specific secrets * Configure multiple secrets in one route **Questions?** Use the support chat! *** * **Level**: beginner * **Time**: 5 minutes `secrets`, `security`, `credentials`, `quickstart` # Securing Your API Routes Source: https://docs.knoxcall.com/security/securing-your-routes Best practices for securing your API routes. Learn about authentication, rate limiting, and security patterns. # Securing Your API Routes Learn best practices for securing your KnoxCall routes. ## Authentication Methods ### 1. OAuth 2.1 Access Tokens (Recommended) Short-lived Bearer tokens (`kc_…`) minted from an OAuth client — nothing long-lived to leak or rotate. The [SDKs](/sdks/overview) mint, cache, and refresh them automatically. **When to use:** * Internal services * Server-to-server communication * New integrations **Configuration:** 1. Register an OAuth client under **Settings** → **API** → **OAuth Clients** (see [Create an OAuth client](/api-reference/authentication#create-an-oauth-client)); on a developer machine, `knoxcall login` does this for you 2. Mint a token via `POST /oauth/token` — or skip this step and let the SDK handle it 3. Clients send: `Authorization: Bearer kc_…` plus `x-knoxcall-route: ` **Legacy API keys** remain supported: generate one under **Routes** → **Clients** and send it as `x-knoxcall-key: tk_…` instead. See [Authentication](/api-reference/authentication). ### 2. JWT Tokens Token-based authentication with expiration. **When to use:** * User-facing applications * Mobile apps * Web applications **Configuration:** 1. Configure JWT settings in route 2. Set token expiration 3. Add public key for verification ### 3. Signature Verification Request signing for tamper protection. **When to use:** * Webhooks * High-security applications * Payment processing **Configuration:** 1. Enable signature verification 2. Share signing secret with clients 3. Clients sign requests with HMAC-SHA256 ## IP Allowlisting Restrict access to specific IP addresses: 1. Navigate to route settings 2. Add allowed IPs: ```text theme={"dark"} 192.168.1.1 10.0.0.0/8 ``` 3. Only these IPs can access the route ## Rate Limiting Protect against abuse and DDoS: **Configuration:** * **Requests**: Number of requests allowed * **Window**: Time window (e.g., 60 seconds) * **Burst**: Allow short bursts **Example:** * 100 requests per minute * Burst: 120 requests ## CORS Configuration Configure cross-origin resource sharing: ```json theme={"dark"} { "allowed_origins": ["https://your-app.com"], "allowed_methods": ["GET", "POST"], "allowed_headers": ["Content-Type", "Authorization"] } ``` ## Security Best Practices ### 1. Use HTTPS Only * Never send credentials over HTTP * Enforce HTTPS in your application ### 2. Rotate Keys Regularly * Prefer short-lived OAuth tokens (`kc_…`) — they expire after an hour, so there is nothing to rotate * If you use long-lived API keys, rotate them every 90 days * Update clients before expiration ### 3. Principle of Least Privilege * Only grant necessary permissions * Use different keys for different services ### 4. Monitor for Anomalies * Set up alerts for unusual activity * Watch for failed auth attempts * Monitor for rate limit violations ### 5. Keep Secrets Secure * Never commit secrets to git * Use environment variables * Rotate secrets immediately if compromised ## Common Security Mistakes ❌ **Don't do this:** * Hardcode API keys in frontend code * Use same key for all environments * Ignore rate limiting * Skip signature verification for webhooks ✅ **Do this instead:** * Store keys in backend environment variables * Use different keys for dev/staging/prod * Always enable rate limiting * Verify webhook signatures ## Audit Logs Review security events: 1. Go to **Audit Logs** 2. Filter by event type 3. Look for suspicious activity *** * **Views**: 1 * **Helpful**: 0 👍 * **Level**: intermediate `security`, `authentication`, `api-keys`, `best-practices` # Encryption threat model Source: https://docs.knoxcall.com/security/threat-model What KnoxCall's portable encryption defends against, the trust boundaries, and — honestly — what it does not protect against. Published so your reviewers can reason about residual risk. # Encryption threat model This page states plainly what the [`kc:` encryption](/essentials/encryption/ciphertext-spec), [vaults](/essentials/vaults/overview), and [capability tokens](/essentials/encryption/overview#client-side-reveal-without-an-api-key) defend against, where the trust boundaries are, and what is explicitly *out* of scope. Naming the residual risk is deliberate — it is how you evaluate whether the control fits your compliance posture. ## Trust boundary Plaintext exists only **transiently**, inside the KnoxCall control plane, during an encrypt or decrypt call. At rest, KnoxCall holds: * **Ciphertext** (`kc:` values, vault `wrapped_value` blobs) — useless without the key. * **Wrapped keys** — app private keys and tenant master keys, each wrapped under the layer above (see the [key hierarchy](/essentials/encryption/ciphertext-spec#key-hierarchy--custody)). In **dual-custody** vaults, KnoxCall holds **no value at all** — you keep the ciphertext. ## What it defends against | Threat | Defence | | - | - | | **Database-only breach** | A dump yields ciphertext + *wrapped* keys. The master key is not in the database in KMS mode; under BYOK it is wrapped by your KMS. No plaintext is recoverable from the DB alone. | | **Cross-tenant access** | `tenant_id` is bound into key derivation (HKDF `info`) and re-checked at decrypt. A ciphertext from tenant A cannot be decrypted as tenant B — the GCM tag fails. | | **Tampering / forgery** | AES-256-GCM authenticates the payload, and the full `kc:` header is bound as additional authenticated data. Flipping any bit of the ciphertext *or* the header fails decryption. | | **Wrong data-role** | The `purpose` (data-role) is folded into the KDF. A value encrypted under `pci` will not decrypt unless `pci` is presented — policy travels with the data. | | **Replay of client reveals** | Capability tokens are single-use (atomically consumed), payload-pinned (bound to one exact ciphertext), and short-lived (≤10 min). A leaked token reveals at most one value, once, within minutes. | | **Sandbox/Test ↔ Live confusion** | Test and Live keys, tokens, and capability tokens are distinct identities. A Test credential never satisfies a Live operation, enforced by a build-failing isolation guard. | | **Cryptographic erasure / right-to-be-forgotten** | Destroying a key version permanently shreds every value under it — no need to hunt through backups. | ## Residual risk (out of scope, stated honestly) * **Simultaneous compromise of the platform master key *and* the database (env-key mode).** If an attacker obtains both `MASTER_KEY_B64` and a DB dump at the same time, wrapped keys become unwrappable and plaintext is recoverable. Mitigations that raise this bar: run in **KMS-unseal** mode (the master key lives in AWS/GCP/Azure KMS, not on disk), adopt **BYOK** (the tenant master key is wrapped by *your* KMS, revocable by you), and — on the roadmap — run the unseal/crypto core inside a **hardware enclave** with remote attestation so even a KnoxCall operator cannot read plaintext. * **A fully-authorised caller.** Encryption does not stop a request that legitimately holds the key and the right data-role. Constrain that with [client permissions](/security/client-permissions), API-key scopes, and capability tokens. * **Plaintext you send us in the clear.** The `/v1/encrypt` request body contains plaintext over TLS. To keep plaintext off our servers entirely, encrypt in the browser against the tenant public key (the kc: format is designed for exactly this) and send only `kc:` values. * **Endpoint compromise on your side.** If your own server or browser is compromised at the moment it handles plaintext, that is outside KnoxCall's boundary. ## Compliance mapping These are **control mappings** — which requirement each mechanism speaks to — not attestations KnoxCall holds. KnoxCall holds no PCI DSS attestation; see the [trust page](https://knoxcall.com/trust) for current status. * **Keeping values out of downstream systems** — tokenize into a managed vault, or seal client-side (`kc:` + browser SDK), so your own services store a token rather than the value. Note the limit: the proxy path never returns the value to you, but a credential that can detokenize can also read it directly via `GET /v1/vaults/:vault/tokens/:token`, so the guarantee is about *where the value flows*, not about what that credential can reach. * **HIPAA / GDPR** — encrypt PHI/PII at the field level; use data-roles for residency (`eu`) and cryptographic erasure (whole-vault) for deletion requests. * **Audit** — every encrypt, decrypt, tokenize, capability-token mint/consume, and API-path detokenize lands in the tamper-evident [audit log](/monitoring/audit-logs). Detokenization inside a proxy request is audited per request with a token count, not per value. ## Verify it yourself The cryptographic construction is published in full in the [ciphertext spec](/essentials/encryption/ciphertext-spec). The `kc:` format is self-describing, so you can independently parse a ciphertext, confirm the curve/IV/tag sizes, and check that the same plaintext encrypts to different ciphertexts every time (fresh ephemeral key + IV per value). # SAML SSO with Auth0 Source: https://docs.knoxcall.com/sso/auth0 Connect Auth0 to KnoxCall over SAML 2.0 — the SAML2 Web App addon, the settings JSON, the NameID Auth0 already gets right, group claims you have to build, assertion encryption, Single Logout and signing-key rotation, with the exact values to paste. # SAML SSO with Auth0 Auth0 federates as an **Application** with the **SAML2 Web App** addon switched on. Everything KnoxCall needs is three values and a certificate, and all three are published on the addon's **Usage** tab. **Before you start** * KnoxCall: an **Enterprise** plan, the **owner or admin** role, and at least one [domain verified for single sign-on](/sso/domains) — a domain you verified for *sending mail* is a different proof and does not admit a login. * Auth0: a tenant administrator who can create applications and edit addons. **Auth0 is most often wired over OIDC, and this page is the SAML path.** That is deliberate, not an oversight: the SSO configuration surface in KnoxCall builds **SAML** connections, and a SAML connection is what these steps produce. `tenant_sso_connections` does carry `oidc_issuer`, `oidc_client_id` and `oidc_client_secret_id`, and its `protocol` column accepts `oidc` as well as `saml` — so the data model has room for an OIDC connection — but **no screen and no API on this surface creates one today**, and nothing authenticates one. Do not plan around it. If OIDC is what you need from Auth0, say so on your support thread rather than filling these fields in and expecting a different protocol to come out. **Configured KnoxCall before 8 September 2026?** Re-enter the values below, or re-import our SP metadata. KnoxCall now **signs AuthnRequests** (RSA-SHA256), publishes an **encryption** key, and publishes a **Single Logout** endpoint. An Auth0 addon configured from the older document has no signature certificate of ours on file, has no encryption key to encrypt to, and has no SLO endpoint of ours to call. None of that is visible from KnoxCall's side. See [the call-out on the overview page](/sso/overview) for the whole story. ## 1. Create the application **Auth0 Dashboard → Applications → Applications → Create Application.** Choose **Regular Web Application**, name it (for example `KnoxCall`), and create it. You will not use its OIDC credentials — the application object is just the thing the SAML addon hangs off. Open the new application, go to the **Addons** tab, and switch **SAML2 WEB APP** on. Auth0 opens the addon's configuration. ## 2. Application Callback URL On the addon's **Settings** tab, the **Application Callback URL** is KnoxCall's assertion consumer service. Replace `acme` with your workspace slug throughout this page. | Auth0 field | Value | | - | - | | **Application Callback URL** | `https://acme.knoxcall.com/saml/acme/acs` | The authoritative copy of every KnoxCall value on this page is your own metadata document at `https://acme.knoxcall.com/saml/acme/metadata` — it is public, so fetch it in a browser and read the values off it rather than assembling them by hand. These are the `default` connection's values — the connection every workspace starts with, and the one the `/saml//…` alias resolves to. If Auth0 is a *second* identity provider for this workspace, it gets its own connection with its own handle, and its values are `/sso///{metadata,acs,login,slo}` — for a handle of `auth0`, `https://acme.knoxcall.com/sso/acme/auth0/acs` and its three siblings. Open that connection under **Settings → Single sign-on** and its **Certificates** tab lists all five, each with a copy button. See [more than one identity provider](/sso/overview#more-than-one-identity-provider). ## 3. The settings JSON Below the callback URL, Auth0 gives you a JSON document rather than a form. Everything else on the IdP side is set here. The keys that matter: ```json theme={"dark"} { "audience": "https://acme.knoxcall.com/saml/acme/metadata", "recipient": "https://acme.knoxcall.com/saml/acme/acs", "destination": "https://acme.knoxcall.com/saml/acme/acs", "nameIdentifierFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", "signatureAlgorithm": "rsa-sha256", "digestAlgorithm": "sha256", "signResponse": false, "mappings": { "user_id": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier", "email": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", "name": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name" } } ``` **`audience` is KnoxCall's metadata URL, and that is not a typo.** KnoxCall's SP entity ID *is* its metadata URL — that is what the metadata document says. Shorten it to the origin and Auth0's audience will not match the one KnoxCall enforces, and every assertion will be rejected. **Leave `signResponse` false.** Auth0 signs the assertion either way, which is what KnoxCall requires; signing the response as well is accepted but buys nothing here. ### The NameID: Auth0 already gets this right, and an edit can break it Auth0's default `mappings` put **`user_id`** on the `…/claims/nameidentifier` claim, and its default `nameIdentifierProbes` reads that claim first. So out of the box the NameID is the Auth0 user id (`auth0|6…`, `google-oauth2|1…`), which is the stable identifier this needs — see [choosing the NameID](/sso/overview#choosing-the-nameid). Of the providers documented here it is the only one whose default is already correct. Two ways to lose it, both of them edits somebody makes for an unrelated reason: * **Replacing `mappings` wholesale.** The addon's `mappings` key is a replacement, not a merge. Write a `mappings` object without a `user_id` entry and the nameidentifier claim disappears; `nameIdentifierProbes` falls through to `email`, and from then on the NameID is the address — which changes when a person is renamed, and a NameID that changes gives the renamed person a second KnoxCall account. * **Setting `nameIdentifierProbes` to `["email"]`,** which some integration guides suggest for providers that require an address. KnoxCall does not. The Auth0 user id is stable for the life of the identity but it is **not** stable across identities: a person who signs into Auth0 with a username/password identity and later with a Google identity has two user ids, and KnoxCall will see two people. Decide which connection your workforce uses before you roll this out, not after. **Then check what Auth0 actually sends.** Once step 6 is done, run a sign-in from the connection's **Test** tab in KnoxCall and read the **NameID** in the verdict: a value like `auth0|6…` means you are done; an address means the connection will keep showing its NameID notice, and it will be right to. ### Attribute mappings KnoxCall reads a plain email and display name and nothing else. Auth0's default mappings already emit both under the standard claim URIs: | Auth0 profile property | SAML claim KnoxCall reads | | - | - | | `email` | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress` | | `name` | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name` | Both claim names are on KnoxCall's default list, so nothing has to be named on the KnoxCall side to read them. **The email claim is required.** With the NameID set to the Auth0 user id, it is the only place KnoxCall finds the person's address — and admission is decided by that address's domain. An Auth0 user whose profile has no `email` cannot be admitted. ## 4. Group claims: Auth0 sends none until you build one **An Auth0 SAML assertion carries no group or role claim by default.** Auth0 has no directory groups of its own — roles live in its Authorization Core, and nothing puts them in a SAML assertion unless you say so. Groups are not needed for sign-in; they only decide whether a *new* person lands as **member** or **readonly**, so the simplest correct configuration is to skip them and set a default role on the KnoxCall connection. If you do want them, add a **Login Action** that writes the names onto the profile, and map that property in the addon: ```js theme={"dark"} exports.onExecutePostLogin = async (event, api) => { const roles = event.authorization?.roles ?? []; api.idToken.setCustomClaim('groups', roles); api.samlResponse.setAttribute('http://schemas.xmlsoap.org/claims/Group', roles); }; ``` `http://schemas.xmlsoap.org/claims/Group` is one of the claim names KnoxCall checks without being told to. Whatever Auth0 emits for each role — role **names**, above — is what you type as the keys of the group map on the KnoxCall connection. The map's values are bounded: a group mapped to anything but `member` or `readonly` is refused at save time, because *"…an IdP group may only map to `member or readonly`…"* — those two and no others. Owner and admin are granted by a human on the team page and by nothing else. ## 5. Encrypted assertions (optional) Auth0's SAML2 addon will encrypt the assertion to KnoxCall's public key. KnoxCall accepts encrypted and plain assertions alike and both take exactly the same path, so this is your call, not ours. Take the certificate from the `use="encryption"` KeyDescriptor in KnoxCall's metadata document — it is the same certificate as the signing one, published twice on purpose, because an IdP reads one descriptor for each job. Then add its PEM and the public key it carries to the addon's settings JSON, under `encryptionCert` and `encryptionPublicKey`, with `encryptionMethod` left at its default. An addon configured before 8 September 2026 has no encryption key of ours at all, because our metadata did not publish one — re-read the document before you try. **Your domain may differ.** Older Auth0 tenants have no region segment at all, newer ones carry one, and a custom domain replaces the host entirely. The placeholders KnoxCall shows use the region-bearing form, so treat them as the shape rather than the value: copy what the SAML2 Web App addon's **Usage** tab actually prints. ## 6. Take the Auth0 values back to KnoxCall Two routes: import Auth0's federation metadata, or type the values by hand. The import is the one that cannot be mis-transcribed. ### Import the federation metadata The addon's **Usage** tab publishes **Identity Provider Metadata** — one document carrying the issuer, the sign-on endpoint and the signing certificate, at `https://..auth0.com/samlp/metadata/`. Hand KnoxCall that link, or the document itself: ```http theme={"dark"} POST /admin/saml/connections//metadata { "url": "https://..auth0.com/samlp/metadata/" } ``` ```http theme={"dark"} POST /admin/saml/connections//metadata { "xml": "" } ``` The call **previews by default** and writes nothing until you send it again with `"apply": true`. What the preview reports, what an import never touches, and every refusal it can answer with are on the [overview](/sso/overview#importing-your-idps-metadata). It is a **snapshot**: nothing re-reads that URL on a schedule, so run the import again when you rotate the tenant signing key. ### Or type the values The addon's **Usage** tab carries all three. In KnoxCall, **Settings → Single sign-on** → your connection: | KnoxCall field | Where it is | Auth0 value | | - | - | - | | **IdP sign-in URL** | Connection tab | **Identity Provider Login URL** (`https://..auth0.com/samlp/`) | | **IdP entity ID (optional)** | Connection tab | **Issuer** (`urn:..auth0.com`) | | **Single Logout URL (optional)** | Connection tab | Auth0's SAML logout endpoint (`https://..auth0.com/samlp//logout`) | | **Only admit specific domains** | Connection tab | leave it off to admit every domain you have proven for single sign-on; tick it to narrow to a list | | **Create accounts automatically on first sign-in** | Connection tab | on, unless you invite everyone by hand | | **Default role** | Attributes & groups tab | `member` or `readonly` | | **IdP signing certificate** | Certificates tab → *Add a certificate* | **Identity Provider Certificate**, downloaded from the Usage tab, with or without the BEGIN/END lines | Auth0's issuer really is a `urn:`, not an address. That is a SAML **entity ID** — a name, not a location; nothing dereferences it, and KnoxCall stores it as given. The two fields that *are* dereferenced, the sign-in URL and the single-logout URL, must both be https and are refused otherwise. Saving needs a re-authentication in the last five minutes; that is expected, and it is the same bar as changing a password. ## 7. Single Logout (optional) Two directions, configured separately. * **Auth0 → KnoxCall.** In the addon settings JSON, set `logout` to `{ "callback": "https://acme.knoxcall.com/saml/acme/slo", "slo_enabled": true }`. Auth0 then sends a signed `LogoutRequest` to that endpoint. * **KnoxCall → Auth0.** Put Auth0's own SLO endpoint into the connection's **Single Logout URL**, as in the table above. It must be https; KnoxCall refuses anything else at save time, because a `LogoutRequest` carries the person's email address as its NameID and the browser is redirected to that URL. Leave the KnoxCall field empty if you do not want the second direction. Empty means "this connection does not do SP-initiated Single Logout" and it never falls back to the sign-in URL. Either way, signing out of KnoxCall always ends the KnoxCall session — never conditionally on the IdP leg. The full behaviour is in [Single Logout](/sso/overview#single-logout). ## 8. Assign people, then test Auth0 does not gate a SAML application by assignment the way Entra and Okta do: **anyone who can authenticate to the connections enabled on this application can obtain an assertion.** Two consequences worth acting on before you go live: * Disable every connection on the application except the one your workforce uses — in particular any database connection with open signup, and any social connection. * Narrow the KnoxCall side too. Tick **Only admit specific domains** on the connection and list the domains that should be admitted; a person who signs up with an address outside them is refused with *"This email domain is not permitted for SAML sign-in to this tenant."* Test with one account: open `https://acme.knoxcall.com/saml/acme/login`, sign in, and check the person lands in the workspace with the role you expected. If it fails, the login page names the cause — every message and code is in [troubleshooting](/sso/troubleshooting). ## The Auth0 dashboard tile Auth0's **Identity Provider Login URL** will accept a sign-in that nobody asked for, which reaches KnoxCall as an **IdP-initiated** sign-in — and that is off unless you turn it on. With it off the person is bounced back to the login page with `saml_idp_initiated_disabled`. Sending people to `https://acme.knoxcall.com/saml/acme/login` instead makes it an ordinary SP-initiated sign-in, which works either way. See [IdP-initiated sign-in](/sso/overview#idp-initiated-sign-in). ## Rotating the Auth0 signing certificate **Auth0's signing key is the tenant's, not the application's.** Rotating it re-signs every SAML application in that Auth0 tenant at the same instant, so schedule it with the other SAML integrations you own, not against this one alone. Rotate **add-first**: 1. In Auth0, **Settings → Signing Keys → Rotate signing key**. Auth0 keeps the previous certificate available on the Usage tab while you finish. 2. Copy the new certificate into the KnoxCall connection. Pasting a certificate **adds** it — a connection can hold several active certificates and an assertion signed by any of them verifies. 3. Confirm a sign-in works from the connection's **Test** tab. 4. Retire the old certificate in KnoxCall. Doing it in that order means there is no instant at which the certificate Auth0 is signing with is not one KnoxCall trusts. Retiring the only active certificate is refused outright: *"This is the connection's only active IdP certificate. Add the new certificate first, then retire this one."* # Verifying a Domain for SSO Source: https://docs.knoxcall.com/sso/domains Prove control of your email domain with a DNS TXT record before your identity provider can assert accounts at it. The exact record, the rules, and every refusal. # Verifying a Domain for SSO Your identity provider signs its own assertions. Nothing in SAML stops it asserting `ceo@some-other-company.com` — the signature proves the message came from your IdP, not that your IdP speaks for that address. KnoxCall therefore admits an assertion only for domains the workspace has **proven** it controls, by publishing a DNS TXT record. Until at least one domain is proven, SAML sign-in cannot create or link any account for the workspace: just-in-time provisioning is refused outright, and a pre-existing account that is not already a member is refused too. ## The record Claim the domain in KnoxCall first — the value contains a token minted for your workspace, so there is nothing to publish until you have it. | | | | - | - | | **Name** | `_knoxcall-verify.acme.com` | | **Type** | `TXT` | | **Value** | `knoxcall-domain-verification=` | The name is the label `_knoxcall-verify` **beneath the domain you are proving** — proving `eu.acme.com` means publishing at `_knoxcall-verify.eu.acme.com`. The value must match exactly, including the `knoxcall-domain-verification=` prefix; a provider that splits long TXT strings into chunks is fine, KnoxCall re-joins them before comparing. ## Adding and verifying a domain **Settings → Single sign-on**, open the connection, then the **Domains** tab. 1. Type the domain and press **Claim domain**. The row appears straight away, marked *Waiting for the TXT record*, carrying the three values to publish — **Record name**, **Record type** and **Record value** — each with a copy button. 2. Publish that TXT record in your DNS, and give it time to propagate. 3. Press **Verify**. KnoxCall resolves the name and compares the value. On success the row becomes *Proven*, with the date. On failure the claim stays exactly where it was and the row shows what the check actually saw, under *Last check reported* — fix the record and press **Verify** again. **Remove** deletes a claim or a proof. Removing a proof stops it admitting logins at the very next sign-in. A domain proven on that tab admits **every** connection in the workspace. (KnoxCall can also honour a proof bound to a single connection, but nothing creates one yet, so every proof you make today is workspace-wide.) Re-claiming a domain you have already claimed returns the **same** token rather than rotating it, so reloading the page, or retrying, never invalidates a record you have already published. ### The same thing from a script The tab is the supported flow; these are the routes underneath it, if you provision workspaces from code. They need an authenticated dashboard session belonging to an **owner or admin** who has re-authenticated in the last 5 minutes. ```http theme={"dark"} POST /admin/saml/domains { "domain": "acme.com" } ``` The response carries the record to publish. Publish it, wait for DNS to propagate, then: ```http theme={"dark"} POST /admin/saml/domains//verify ``` `GET /admin/saml/domains` lists what you have claimed and proven; `DELETE /admin/saml/domains/` removes a claim or a proof. ## What a proven domain covers * **Exactly the domain, and anything beneath it.** Proving `acme.com` admits `alice@acme.com` and `bob@eu.acme.com`. * **Never a parent.** Proving `eu.acme.com` does not admit `alice@acme.com`. That direction is the one an attacker would want. * **Proof is read live, on every assertion.** Removing a proof stops it admitting logins at the next sign-in — there is no cached grant to expire. You can also narrow admission further with the connection's **verified email domains** list. That list is a filter over your proven domains, not an authority of its own: putting a domain in it that you have not proven is refused, and a domain on it whose proof you later delete admits nobody at the very next sign-in. The settings form requires at least one entry; a connection carrying none admits every domain you have proven. **A domain verified for sending mail does not admit an SSO login.** They are two proofs about two different questions, and since 8 September 2026 `tenant_sso_domains` — the table this page writes — is the only authority KnoxCall consults when deciding whose IdP-asserted address may claim a KnoxCall account. Mail verification is written by an onboarding flow and by an unattended job, and its rules were written for undelivered mail, not for account takeover. If you verified a domain for email, verify it again here: the same style of TXT record, under a different name, and it takes a minute. Nothing broke at the changeover — every mail-verified domain that a configured SSO connection was relying on was carried across once. New domains are proven here only. ## Rules a domain has to satisfy | Rule | Why | | - | - | | A real, public domain | *"Enter a full domain, for example acme.com — a single label cannot be proven."* Reserved names (`.local`, `.internal`, `example.com`, and the rest of RFC 2606/6761) are refused: nobody can hold them on the public internet, so control of them cannot be proven. | | Not a shared mailbox provider | *"`gmail.com` is a shared mailbox provider. Proving it would let this tenant claim any account at that domain, so it can never be used for single sign-on. Use a domain your company controls."* | | Not a KnoxCall domain | Proving one would let a workspace claim KnoxCall staff accounts. | | Not an IP address | An address is not a domain. | | Not already **proven** by another workspace | Competing *unverified* claims are allowed — the race is decided at verification, not at claim time, so nobody can squat a domain by typing it into a trial account. | ## When verification fails Each of these leaves the claim in place; fix the cause and verify again. | Message | What happened | What to do | | - | - | - | | *"No TXT record was found at `_knoxcall-verify.acme.com`. DNS changes can take a few minutes to propagate — publish the record and try again."* | Nothing resolves at that name yet. | Check the record name — many DNS UIs append the zone for you, so entering the full name can produce `_knoxcall-verify.acme.com.acme.com`. Then wait out the TTL. | | *"The TXT record at `_knoxcall-verify.acme.com` does not contain this account's verification value…"* | A TXT record exists, but not ours. | Copy the value again, including the `knoxcall-domain-verification=` prefix and with no surrounding quotes added by the DNS editor. | | *"That domain could not be verified for this account. If you control it and this is unexpected, contact support with the domain name and we will look into it."* | Another workspace already holds a **verified** proof for this domain. | Contact support. The message is deliberately uninformative — it must not report which other customer holds a domain. | | *"`acme.com` is a reserved name that nobody can hold on the public internet…"* / *"…is a shared mailbox provider…"* | The domain can never be proven. | Use a domain your company controls in public DNS. | ## Two refusals you will meet on the connection, not the domain * *"At least one verified email domain is required. Without it, SAML sign-in cannot create or link any account for this tenant — just-in-time provisioning is refused outright, and a pre-existing account that is not already a member is refused too."* — you saved a connection with an empty domain list. * *"`eu.acme.com` is not covered by a domain you have verified for single sign-on (`acme-corp.com`). A SAML assertion is signed by your own IdP, so KnoxCall only lets it claim addresses at domains you have proven you control under Settings → Single sign-on → Domains."* — the list names a domain whose proof does not cover it. Prove that domain on the **Domains** tab, or list one that is covered. * *"You have not verified any domain for single sign-on yet, so `acme.com` cannot be trusted for SAML sign-in. … A domain verified for sending mail is a separate proof and does not count here…"* — you have proven nothing under **Single sign-on → Domains**. Do that first; a mail-sending proof is not accepted here (see the note above). Next: pick your provider — [Microsoft Entra ID](/sso/entra-id), [Okta](/sso/okta), [Google Workspace](/sso/google-workspace), [JumpCloud](/sso/jumpcloud), [Auth0](/sso/auth0), [OneLogin](/sso/onelogin) or [PingOne](/sso/pingone). Running something else? Any SAML 2.0 provider works — the [overview](/sso/overview) has the generic route. # SAML SSO with Microsoft Entra ID Source: https://docs.knoxcall.com/sso/entra-id Connect Microsoft Entra ID (Azure AD) to KnoxCall over SAML 2.0 — enterprise application, claims, group claims, token encryption and Single Logout, with the exact values to paste. # SAML SSO with Microsoft Entra ID This is the full path for Entra ID (formerly Azure AD): create the enterprise application, exchange metadata, map claims, and — optionally — switch on token encryption and Single Logout. **Before you start** * KnoxCall: an **Enterprise** plan, the **owner or admin** role, and at least one [domain verified for single sign-on](/sso/domains) — a domain you verified for *sending mail* is a different proof and does not admit a login. * Entra: permission to create and manage enterprise applications (Application Administrator or better). Token encryption additionally needs **Entra ID P1 or P2**. **Configured KnoxCall before 8 September 2026?** Re-upload our SP metadata (or re-import the metadata URL) before you go further. KnoxCall now signs AuthnRequests, publishes an encryption key, and publishes a Single Logout endpoint — an Entra app built from the older document will not offer **Token encryption** at all, may reject our signed requests, and has no SLO URL for us. See [the call-out on the overview page](/sso/overview) for the whole story. ## App registration or enterprise application? Both objects exist for every app, and only one of them does SAML. * An **App registration** defines an application and its API permissions. It is the OpenID Connect / OAuth object. **SAML is not configured here.** * An **Enterprise application** is the instance of an app in *your* tenant — assignments, conditional access, claims, and **Single sign-on → SAML**. For KnoxCall you create a **non-gallery enterprise application**. Creating it also creates the underlying registration; you never need to open it. ## 1. Create the application 1. Entra admin centre → **Enterprise applications** → **New application** → **Create your own application**. 2. Name it (for example `KnoxCall`), choose **Integrate any other application you don't find in the gallery (Non-gallery)**, and create. ## 2. Give Entra the KnoxCall URLs Open the app → **Single sign-on** → **SAML**. The fastest route is **Upload metadata file**: fetch `https://acme.knoxcall.com/saml/acme/metadata` (replace `acme` with your workspace slug — it is public, so a browser will do) and upload the XML. Entra fills in the identifier, reply URL and logout URL from the document. To type them instead, edit **Basic SAML Configuration**: | Entra field | Value | | - | - | | **Identifier (Entity ID)** | `https://acme.knoxcall.com/saml/acme/metadata` | | **Reply URL (Assertion Consumer Service URL)** | `https://acme.knoxcall.com/saml/acme/acs` | | **Sign on URL** | `https://acme.knoxcall.com/saml/acme/login` | | **Logout Url** | `https://acme.knoxcall.com/saml/acme/slo` | The same values are in the dashboard under **Settings → Single sign-on**, ready to copy. These are the `default` connection's values — the connection every workspace starts with, and the one the `/saml//…` alias resolves to. If Entra ID is a *second* identity provider for this workspace, it gets its own connection with its own handle, and its values are `/sso///{metadata,acs,login,slo}` — for a handle of `entra`, `https://acme.knoxcall.com/sso/acme/entra/metadata` and its three siblings. Open that connection under **Settings → Single sign-on** and its **Certificates** tab lists all five, each with a copy button. See [more than one identity provider](/sso/overview#more-than-one-identity-provider). Uploading metadata is a **snapshot**. Entra does not re-read the document later, so whenever KnoxCall's SP metadata changes you re-upload it. That is the entire reason for the call-out at the top of this page. The exchange in the other direction — KnoxCall reading *Entra's* metadata — is [step 5](#5-take-the-entra-values-back-to-knoxcall), and it is a snapshot for the same reason. ## 3. Configure claims Select **Edit** in the **Attributes & Claims** section, then: * **The NameID — make it a stable identifier.** Open the **Name identifier value** and choose the `objectid` source attribute, which Microsoft describes as "The object ID of the user in Microsoft Entra ID". It does not change when the person is renamed. Entra's default is the user principal name, which usually does — and a NameID that changes on a rename gives the renamed person a second KnoxCall account. Why this matters, and why KnoxCall does not merge the two afterwards: [choosing the NameID](/sso/overview#choosing-the-nameid). * **The address — keep the `.../claims/emailaddress` claim**, and check that its source attribute holds a real address at a domain you have verified for everyone who will sign in. With a stable NameID, this claim is the only place KnoxCall finds the person's address, so a person whose source attribute is empty cannot be admitted. * Keep the default `.../claims/displayname` claim. KnoxCall reads it, and the address claim, without any configuration. **Then check what Entra actually sends.** KnoxCall's sign-in request names the email-address NameID format, and Microsoft documents that Entra "honors the format in the request" when a request names one; it does not document which value accompanies that format once the source is `objectid`. So once step 5 is done, run a sign-in from the connection's **Test** tab in KnoxCall and read the **NameID** in the verdict. A GUID means you are done. An address means Entra is still sending one — the connection will keep showing its NameID notice, and it will be right to. KnoxCall reads nothing else from the assertion. There is no role claim to send: an IdP-asserted login may only ever confer **member** or **readonly** (see [roles](/sso/overview#roles-what-an-assertion-may-and-may-not-do)). ## 4. Group claims (optional) Only needed if you want group membership to decide whether a new person lands as **member** or **readonly**. In **Attributes & Claims** → **Add a group claim**: * Choose **Groups assigned to the application**. Not "All groups" — Entra truncates large group claims, and the assigned set is the one that describes this app. * **Source attribute**: `Group ID` emits object GUIDs; a directory synced from Active Directory can emit `sAMAccountName` instead. Whatever you choose is what you map in KnoxCall — if Entra sends GUIDs, the KnoxCall group map keys are those GUIDs. * **Customize the name of the group claim** → *emit groups as role claims* changes the claim name from `http://schemas.microsoft.com/ws/2008/06/identity/claims/groups` to the role URN. KnoxCall reads the **groups** URN out of the box; if you emit as roles, name the role claim as the connection's group attribute or the map will never match. Map groups to roles on the KnoxCall connection. The bound is absolute — a group mapped to anything but `member` or `readonly` is refused at save time: *"Group `'Engineering'` maps to `'owner'`; an IdP group may only map to `member or readonly` — an IdP-asserted login may never mint an administrator (elevate members on the team page)."* ## 5. Take the Entra values back to KnoxCall There are two routes: import Entra's federation metadata, or type the three values by hand. The import is the one that cannot be mis-transcribed. ### Import the federation metadata Entra publishes the entity ID, the sign-on endpoints, the logout endpoint and the signing certificates in one document. On the **Single sign-on** blade, section 3 (**SAML Certificates**) carries both forms of it: **App Federation Metadata Url**, and **Federation Metadata XML** as a download. Hand either one to KnoxCall: ```http theme={"dark"} POST /admin/saml/connections//metadata { "url": "https://login.microsoftonline.com//federationmetadata/2007-06/federationmetadata.xml" } ``` ```http theme={"dark"} POST /admin/saml/connections//metadata { "xml": "" } ``` One or the other, never both — *"Send either — the document itself — or , where to fetch it from — but not both."* The `` is the connection's id, which is in the address bar when you have the connection open. **The call previews by default; nothing is written until you send it again with `"apply": true`** — what the preview reports, what an import never touches, and every refusal it can answer with are on the [overview](/sso/overview#importing-your-idps-metadata). Two Entra-specific notes: * An Entra export made before you switched Single Logout on has no `SingleLogoutService`; the import leaves your stored SLO URL alone rather than reading the absence as "turn it off". * KnoxCall stores the URL you imported from, but **nothing re-reads it on a schedule**. When Entra rolls its signing certificate, run the import again — or paste the new certificate — which is the add-first rotation at the bottom of this page. ### Or type the three values Still on the **Single sign-on** blade: * **SAML Certificates** → download **Certificate (Base64)**. * **Set up KnoxCall** → copy the **Login URL** and the **Microsoft Entra Identifier**. In KnoxCall, **Settings → Single sign-on** → your connection: | KnoxCall field | Where it is | Entra value | | - | - | - | | **IdP sign-in URL** | Connection tab | Login URL (`https://login.microsoftonline.com//saml2`) | | **IdP entity ID (optional)** | Connection tab | Microsoft Entra Identifier (`https://sts.windows.net//`) | | **Single Logout URL (optional)** | Connection tab | the endpoint Entra publishes for logout (`https://login.microsoftonline.com//saml2`), which is the same address as the Login URL — see [step 8](#8-single-logout-optional) | | **Only admit specific domains** | Connection tab | leave it off to admit every domain you have proven; tick it to narrow to a list | | **Create accounts automatically on first sign-in** | Connection tab | on, unless you invite everyone by hand | | **Default role** | Attributes & groups tab | `member` or `readonly` | | **IdP signing certificate** | Certificates tab → *Add a certificate* | the Base64 certificate you downloaded (with or without the BEGIN/END lines) | Save. Saving needs a re-authentication in the last five minutes; that is expected. ## 6. Assign users, then test **Users and groups** → assign the people or groups who should have KnoxCall. Entra refuses sign-in to anyone unassigned, before KnoxCall ever sees a request. Test with one account: open `https://acme.knoxcall.com/saml/acme/login`, sign in, and check the person lands in the workspace with the role you expected. If it fails, the message on the login page names the cause — see [troubleshooting](/sso/troubleshooting). ## 7. Token encryption (optional) Entra can encrypt the assertion to KnoxCall's public key. KnoxCall accepts encrypted and plain assertions alike, so this is your call, not ours. 1. Fetch KnoxCall's SP metadata and take the certificate from the `use="encryption"` KeyDescriptor. It is the same certificate as the signing one, published twice on purpose — Entra reads the encryption descriptor and nothing else when offering this feature. 2. Save it as a `.cer` file. 3. Enterprise application → **Token encryption** → **Import Certificate** → **Activate**. If **Token encryption** is missing from the app's menu, either your Entra edition does not include it or the app was configured from SP metadata that predates our encryption key — re-upload the metadata. ## 8. Single Logout (optional) Entra sends a `LogoutRequest` to the **Logout Url** you set in step 2. Nothing else is needed for the IdP-initiated direction. For the other direction — a person signing out of KnoxCall also signing out of Entra — set the connection's **Single Logout URL** to `https://login.microsoftonline.com//saml2` (the same endpoint Entra publishes for logout in its federation metadata). It must be **https**, and KnoxCall refuses anything else at save time — the refusal names the field and says why, because a `LogoutRequest` carries the person's email address as the NameID and the browser is redirected to that URL. ## Rotating the Entra certificate Entra certificates expire, and Entra will email the address in **Notification Email Addresses** before they do. Rotate add-first: 1. In Entra, create the new signing certificate but leave the current one active. 2. Add the new certificate to the KnoxCall connection. Pasting a certificate **adds** it — both verify while both are active. 3. Make the new certificate active in Entra. 4. Retire the old certificate in KnoxCall. Retiring the only active certificate is refused: *"This is the connection's only active IdP certificate. Add the new certificate first, then retire this one."* ## The app launcher tile Clicking KnoxCall in the Microsoft app launcher is an **IdP-initiated** sign-in, and it is off unless you turn it on. With it off the person is bounced back to the login page with `saml_idp_initiated_disabled`. See [IdP-initiated sign-in](/sso/overview#idp-initiated-sign-in) for how to switch it on and what it costs. # SAML SSO with Google Workspace Source: https://docs.knoxcall.com/sso/google-workspace Connect Google Workspace to KnoxCall over SAML 2.0 — the custom SAML app, ACS and entity ID, attribute mapping, what Google does not send, and certificate rotation. # SAML SSO with Google Workspace Google Workspace federates as a **custom SAML app**. It is the simplest setup of any provider documented here and the most limited: Google signs assertions and nothing else — no assertion encryption, no Single Logout, and no group claim unless you build one. **Before you start** * KnoxCall: an **Enterprise** plan, the **owner or admin** role, and at least one [domain verified for single sign-on](/sso/domains) — a domain you verified for *sending mail* is a different proof and does not admit a login. * Google: a super administrator, at admin.google.com. **Configured KnoxCall before 8 September 2026?** Re-check the values below. KnoxCall now **signs AuthnRequests** (RSA-SHA256), publishes an **encryption** key, and publishes a **Single Logout** endpoint. Google ignores all three — it does not verify signed AuthnRequests, does not offer assertion encryption for custom SAML apps, and publishes no single-logout endpoint for one — so a Google-federated workspace loses nothing by having been configured earlier. Confirm the ACS URL and entity ID below still match your metadata document exactly, and if you federate a second, more capable IdP as well, re-upload our metadata **there**. See [the overview](/sso/overview) for what changed. ## 1. Create the app **admin.google.com → Apps → Web and mobile apps → Add app → Add custom SAML app.** Name it (for example `KnoxCall`), add a logo if you like, and continue. ## 2. Copy Google's IdP details first Google shows them on the next screen, before it asks you for anything. Take all three now — you paste them into KnoxCall in step 5: * **SSO URL** — `https://accounts.google.com/o/saml2/idp?idpid=` * **Entity ID** — `https://accounts.google.com/o/saml2?idpid=` * **Certificate** — download it, or copy the PEM ## 3. Service provider details Replace `acme` with your workspace slug. The authoritative copy of these values is your own metadata document at `https://acme.knoxcall.com/saml/acme/metadata`, which is public — fetch it in a browser and read them off it rather than assembling them by hand. | Google field | Value | | - | - | | **ACS URL** | `https://acme.knoxcall.com/saml/acme/acs` | | **Entity ID** | `https://acme.knoxcall.com/saml/acme/metadata` | | **Start URL** | `https://acme.knoxcall.com/saml/acme/login` | | **Signed response** | leave unchecked — Google signs the assertion either way, which is what KnoxCall requires | | **Name ID format** | the email-address format, matching the `urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress` that KnoxCall's sign-in request names | | **Name ID** | a custom attribute holding a stable identifier — see below | KnoxCall's SP entity ID *is* its metadata URL. That is what the metadata document says; do not shorten it to the origin, or Google's audience will not match the one KnoxCall enforces and every assertion will be rejected. **The Name ID should be a stable identifier.** Google's own guide says "The default Name ID is the primary email", and a primary email changes when a person is renamed — which gives them a second KnoxCall account, because KnoxCall recognises people by their NameID ([choosing the NameID](/sso/overview#choosing-the-nameid)). Google lets you create custom attributes, in the Admin console or through the Admin SDK, and map the Name ID to one. Put a value there that stays the same when a person is renamed — an employee number you already keep, for example — and fill it in for everyone who will sign in. If you cannot, the primary email still works: sign-ins succeed, the connection shows its NameID notice, and a rename will create a second account. Once step 6 is done, run a sign-in from the connection's **Test** tab in KnoxCall and read the **NameID** in the verdict to confirm what Google actually sends. These are the `default` connection's values — the connection every workspace starts with, and the one the `/saml//…` alias resolves to. If Google Workspace is a *second* identity provider for this workspace, it gets its own connection with its own handle, and its values are `/sso///{metadata,acs,login,slo}` — for a handle of `google`, `https://acme.knoxcall.com/sso/acme/google/acs` and its three siblings. Open that connection under **Settings → Single sign-on** and its **Certificates** tab lists all five, each with a copy button. See [more than one identity provider](/sso/overview#more-than-one-identity-provider). Google has no field for a KnoxCall **Single Logout** URL, because Google Workspace does not implement SAML SLO for custom SAML apps. Leave the KnoxCall connection's **Single Logout URL** empty — empty means "this connection does not do SP-initiated Single Logout", and it never falls back to the sign-in URL. Signing a person out of KnoxCall still ends their KnoxCall session; it does not end their Google session, and suspending them in Google is what stops the next sign-in. ## 4. Attribute mapping **The `email` mapping is required.** With a stable Name ID from step 3, it is the only place KnoxCall finds the person's address — and admission is decided by that address's domain: | Google directory attribute | App attribute | | - | - | | Basic Information → Primary email | `email` | | Basic Information → First name | `firstName` | | Basic Information → Last name | `lastName` | `email` is on KnoxCall's default list and is read without configuration. **Display name.** Google has no single "display name" directory attribute to map, so KnoxCall will not find one — the account is created with the local part of the email address as its name, and the person can change it in their profile. If you keep a full name in a custom schema, map that attribute and name it `displayName`, which is on KnoxCall's default list. ## 5. Group claims: Google sends none by default **There is no group claim in a Google Workspace SAML assertion unless you add one**, and even then Google only emits the groups you explicitly list on the app. Groups are not needed for sign-in — they only decide whether a *new* person lands as **member** or **readonly** — so the simplest correct configuration is to skip them and set a default role on the KnoxCall connection. If you do want them: in the app's **SAML attribute mapping**, add a **Group membership** mapping, select the groups it should report, and give the app attribute the name `groups`. `groups` is on KnoxCall's default list. Whatever Google emits for each group is what you type as the keys of the group map on the KnoxCall connection. The map's values are bounded either way: a group mapped to anything but `member` or `readonly` is refused at save time, because *"…an IdP group may only map to `member or readonly`…"* — those two and no others. Owner and admin are granted by a human on the team page and by nothing else — no Google group, however named, can produce one. ## 6. Take the Google values back to KnoxCall ### Import the metadata Google gave you Google's SAML app screen offers the same values as a **metadata download**, and that document is one KnoxCall's importer accepts: a single `` in the SAML 2.0 metadata namespace carrying an ``, which is exactly what it requires. Paste its contents: ```http theme={"dark"} POST /admin/saml/connections//metadata { "xml": "" } ``` The call **previews by default** and writes nothing until you send it again with `"apply": true`; what the preview reports and every refusal it can answer with are on the [overview](/sso/overview#importing-your-idps-metadata). Google's document carries **no** `SingleLogoutService` — its custom-SAML-app setup hands over an SSO URL, an entity ID and a certificate, and nothing else — the import leaves KnoxCall's single-logout field exactly as it found it rather than reading that absence as an instruction. ### Or type the values In KnoxCall, **Settings → Single sign-on** → your connection: | KnoxCall field | Where it is | Google value | | - | - | - | | **IdP sign-in URL** | Connection tab | the **SSO URL** from step 2 (`https://accounts.google.com/o/saml2/idp?idpid=`) | | **IdP entity ID (optional)** | Connection tab | the **Entity ID** from step 2 (`https://accounts.google.com/o/saml2?idpid=`) | | **Single Logout URL (optional)** | Connection tab | leave empty — Google does not support SLO | | **Only admit specific domains** | Connection tab | leave it off to admit every domain you have proven for single sign-on; tick it to narrow to a list | | **Create accounts automatically on first sign-in** | Connection tab | on, unless you invite everyone by hand | | **Default role** | Attributes & groups tab | `member` or `readonly` | | **IdP signing certificate** | Certificates tab → *Add a certificate* | the certificate you downloaded, with or without the BEGIN/END lines | Saving needs a re-authentication in the last five minutes; that is expected. ## 7. Turn the app on, then test Back in the Google admin console, **User access** → turn the service **ON for everyone**, or on for the organisational units and groups that should have KnoxCall. Google's own changes can take a few minutes to reach the sign-in servers. Test with one account: open `https://acme.knoxcall.com/saml/acme/login`, sign in, and check the person lands in the workspace with the role you expected. If it fails, the login page names the cause — every message and code is in [troubleshooting](/sso/troubleshooting). ## The Google app tile Clicking KnoxCall in Google's app launcher uses the **Start URL** you set in step 3, so it is an ordinary **SP-initiated** sign-in and works with IdP-initiated login switched off. That is one reason to fill the Start URL in: leave it blank and the tile produces an unsolicited assertion instead, which KnoxCall refuses with `saml_idp_initiated_disabled` unless you have deliberately opted in. ## Rotating the Google certificate Google lets an app hold two SAML certificates at once, which is exactly the overlap this needs. Rotate **add-first**: 1. In the Google admin console, open the app and add a second certificate. Leave the first in place. 2. Copy the new certificate into the KnoxCall connection. Pasting a certificate **adds** it — a connection can hold several active certificates and an assertion signed by any of them verifies. 3. Switch Google over to the new certificate. 4. Retire the old certificate in KnoxCall. Retiring the only active certificate is refused: *"This is the connection's only active IdP certificate. Add the new certificate first, then retire this one."* # SAML SSO with JumpCloud Source: https://docs.knoxcall.com/sso/jumpcloud Connect JumpCloud to KnoxCall over SAML 2.0 — the custom SSO application, IdP URL, user and group attributes, Single Logout and certificate rotation. # SAML SSO with JumpCloud JumpCloud federates through an **SSO application**. There is no KnoxCall entry in JumpCloud's catalogue, so you create a **Custom SAML App** — which needs the same four URLs and one certificate as every other provider here. **Before you start** * KnoxCall: an **Enterprise** plan, the **owner or admin** role, and at least one [domain verified for single sign-on](/sso/domains) — a domain you verified for *sending mail* is a different proof and does not admit a login. * JumpCloud: an administrator who can create SSO applications. **Configured KnoxCall before 8 September 2026?** Re-upload our SP metadata, or re-enter the values below, before you go further. KnoxCall now **signs AuthnRequests** (RSA-SHA256), publishes an **encryption** key so an IdP can encrypt assertions to us, and publishes a **Single Logout** endpoint. A JumpCloud application built from the older document has no signature certificate of ours on file, cannot be given our encryption certificate, and has no SLO endpoint of ours to call — none of which is visible from KnoxCall's side. See [the call-out on the overview page](/sso/overview) for the whole story. ## 1. Create the application **JumpCloud admin console → SSO Applications → Get Started / + Add New Application → Custom Application → Manage Single Sign-On (SSO) → Configure SSO with SAML.** Give it a display name (for example `KnoxCall`) and continue to the SSO tab. ## 2. Service provider details Replace `acme` with your workspace slug. The authoritative copy of every value is your own metadata document at `https://acme.knoxcall.com/saml/acme/metadata` — it is public, so fetch it in a browser and read the values off it rather than assembling them by hand. JumpCloud will also accept the document itself under **Upload Metadata**, which fills these fields in for you. | JumpCloud field | Value | | - | - | | **SP Entity ID** | `https://acme.knoxcall.com/saml/acme/metadata` | | **ACS URL** | `https://acme.knoxcall.com/saml/acme/acs` | | **SAMLSubject NameID** | a custom attribute holding a stable identifier — see below | | **SAMLSubject NameID Format** | `urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress` | | **Signature Algorithm** | `RSA-SHA256` | | **Sign Assertion** | on | | **Login URL** (the app tile's destination) | `https://acme.knoxcall.com/saml/acme/login` | | **IdP Entity ID** | yours — JumpCloud proposes a value; keep it and paste it into KnoxCall in step 5 | KnoxCall's SP entity ID *is* its metadata URL. That is what the metadata document says; do not shorten it to the origin, or JumpCloud's audience will not match the one KnoxCall enforces and every assertion will be rejected. **The NameID should be a stable identifier.** JumpCloud's guide says "By default, the SAMLSubject's NameID is email, but can be changed to any of the attributes in the dropdown list, including a custom attribute." An email changes when a person is renamed, which gives them a second KnoxCall account, because KnoxCall recognises people by their NameID ([choosing the NameID](/sso/overview#choosing-the-nameid)). Choose a custom attribute whose value stays the same when a person is renamed — an employee number you already keep, for example — and fill it in for everyone who will sign in. If you cannot, email still works: sign-ins succeed, the connection shows its NameID notice, and a rename will create a second account. Keep the NameID Format on the email-address format above, which is the one KnoxCall's sign-in request names. Once step 5 is done, run a sign-in from the connection's **Test** tab in KnoxCall and read the **NameID** in the verdict to confirm what JumpCloud actually sends. These are the `default` connection's values — the connection every workspace starts with, and the one the `/saml//…` alias resolves to. If JumpCloud is a *second* identity provider for this workspace, it gets its own connection with its own handle, and its values are `/sso///{metadata,acs,login,slo}` — for a handle of `jumpcloud`, `https://acme.knoxcall.com/sso/acme/jumpcloud/acs` and its three siblings. Open that connection under **Settings → Single sign-on** and its **Certificates** tab lists all five, each with a copy button. See [more than one identity provider](/sso/overview#more-than-one-identity-provider). Setting **Login URL** to the KnoxCall login endpoint makes the JumpCloud User Portal tile an ordinary **SP-initiated** sign-in, which works with IdP-initiated login switched off. Leave it blank and the tile produces an unsolicited assertion instead, which KnoxCall refuses with `saml_idp_initiated_disabled` unless you have deliberately opted in — see [IdP-initiated sign-in](/sso/overview#idp-initiated-sign-in). ## 3. User attributes Under **User Attribute Mapping**, add two: | Service provider attribute name | JumpCloud attribute | | - | - | | `email` | `email` | | `displayName` | `displayname` | Both service-provider names are on KnoxCall's default list, so nothing has to be named on the KnoxCall side to read them. **The `email` attribute is required:** with a stable NameID from step 2, it is the only place KnoxCall finds the person's address — and admission is decided by that address's domain. ## 4. Group attributes (optional) Only needed if you want group membership to decide whether a new person lands as **member** or **readonly**. Tick **Group Attributes** and set the **Group Attribute Name** to `memberOf` — one of the names KnoxCall checks without being told to. JumpCloud then emits the names of the user groups that are bound to this application. Only groups **assigned to this application** appear in the claim, which is the behaviour you want: the claim describes access to KnoxCall rather than the whole directory. Whatever JumpCloud emits — group names — is what you type as the keys of the group map on the KnoxCall connection. The map's values are bounded: a group mapped to anything but `member` or `readonly` is refused at save time, because *"…an IdP group may only map to `member or readonly`…"* — those two and no others. Owner and admin are granted by a human on the team page and by nothing else. ## 5. Take the JumpCloud values back to KnoxCall Two routes: import JumpCloud's metadata, or type the values by hand. The import is the one that cannot be mis-transcribed. ### Import the federation metadata The application's SSO tab exports JumpCloud's **IdP metadata** as an XML file — one document carrying the entity ID, the IdP URL and the signing certificate. Paste its contents: ```http theme={"dark"} POST /admin/saml/connections//metadata { "xml": "" } ``` The call **previews by default** and writes nothing until you send it again with `"apply": true`. What the preview reports, what an import never touches, and every refusal it can answer with are on the [overview](/sso/overview#importing-your-idps-metadata). Like the metadata you uploaded to JumpCloud, it is a **snapshot** — export and import it again when JumpCloud rolls the application's certificate. ### Or type the values Still on the application's SSO tab, download the **IdP Certificate**, and note the **IdP URL** JumpCloud has assigned (it looks like `https://sso.jumpcloud.com/saml2/`). In KnoxCall, **Settings → Single sign-on** → your connection: | KnoxCall field | Where it is | JumpCloud value | | - | - | - | | **IdP sign-in URL** | Connection tab | the **IdP URL** (`https://sso.jumpcloud.com/saml2/`) | | **IdP entity ID (optional)** | Connection tab | the **IdP Entity ID** from step 2 — JumpCloud proposes it and lets you change it, so there is no shape to predict; read it off the application's SSO tab | | **Single Logout URL (optional)** | Connection tab | JumpCloud's own logout endpoint, read off the application's SSO tab, where your JumpCloud application exposes one — leave it empty if yours does not, and see [Single Logout](#single-logout-optional) | | **Only admit specific domains** | Connection tab | leave it off to admit every domain you have proven for single sign-on; tick it to narrow to a list | | **Create accounts automatically on first sign-in** | Connection tab | on, unless you invite everyone by hand | | **Default role** | Attributes & groups tab | `member` or `readonly` | | **IdP signing certificate** | Certificates tab → *Add a certificate* | the certificate you downloaded, with or without the BEGIN/END lines | Saving needs a re-authentication in the last five minutes; that is expected, and it is the same bar as changing a password. ## 6. Assign users, then test **User Groups** on the application → bind the groups who should have KnoxCall, and save. JumpCloud refuses sign-in to anyone unbound before KnoxCall ever sees a request. Test with one account: open `https://acme.knoxcall.com/saml/acme/login`, sign in, and check the person lands in the workspace with the role you expected. If it fails, the login page names the cause — every message and code is in [troubleshooting](/sso/troubleshooting). ## Encrypted assertions (optional) Where your JumpCloud application offers assertion encryption, upload the certificate from the `use="encryption"` KeyDescriptor in KnoxCall's metadata document. It is the same certificate as the signing one, published twice on purpose, because an IdP reads one descriptor for each job. KnoxCall accepts encrypted and plain assertions alike and both take exactly the same path, so this is your call, not ours — and an application configured before 8 September 2026 will not offer the option at all until our metadata is re-uploaded. ## Single Logout (optional) Where your JumpCloud application exposes a logout endpoint, the two directions are configured separately: * **JumpCloud → KnoxCall.** Give JumpCloud KnoxCall's SLO URL, `https://acme.knoxcall.com/saml/acme/slo`, and the certificate from the `use="signing"` KeyDescriptor so it can verify the messages KnoxCall signs. A `LogoutRequest` naming the person's NameID ends the sessions this workspace's IdP authenticated. * **KnoxCall → JumpCloud.** Put JumpCloud's own SLO endpoint into the KnoxCall connection's **Single Logout URL**. It must be https; KnoxCall refuses anything else at save time, because a `LogoutRequest` carries the person's email address as its NameID and the browser is redirected to that URL. **Do not paste `https://console.jumpcloud.com/userconsole/` here.** JumpCloud's own documentation suggests that URL wherever a service provider offers an "IDP Logout URL" field, and it is the first answer most administrators find. It is a page to send a browser to after signing out — not a SAML endpoint. KnoxCall's **Single Logout URL** is where we send a `LogoutRequest`, so a console address accepts nothing, answers nothing, and the control simply never works. Nothing in KnoxCall can detect that for you: the field saves cleanly because it is a valid https URL. Leave the KnoxCall field empty if your application has no such endpoint. Empty means "this connection does not do SP-initiated Single Logout" and it never falls back to the sign-in URL. Either way, signing out of KnoxCall always ends the KnoxCall session — never conditionally on the IdP leg. The full behaviour is in [Single Logout](/sso/overview#single-logout). ## Rotating the JumpCloud certificate JumpCloud's application certificate expires. Rotate **add-first**: 1. Generate the new certificate in JumpCloud, leaving the current one in use. 2. Copy the new certificate into the KnoxCall connection. Pasting a certificate **adds** it — a connection can hold several active certificates and an assertion signed by any of them verifies. 3. Switch JumpCloud over to signing with the new key. 4. Retire the old certificate in KnoxCall. Doing it in that order means there is no instant at which the certificate JumpCloud is signing with is not one KnoxCall trusts. Retiring the only active certificate is refused: *"This is the connection's only active IdP certificate. Add the new certificate first, then retire this one."* # SAML SSO with Okta Source: https://docs.knoxcall.com/sso/okta Connect Okta to KnoxCall over SAML 2.0 — the SAML app integration, attribute and group statements, assertion encryption, Single Logout and certificate rotation, with the exact values to paste. # SAML SSO with Okta Okta's **SAML 2.0 app integration** is the object that does this. There is no KnoxCall entry in the Okta Integration Network, so you create your own — which is fine, because everything KnoxCall needs is four URLs and a certificate. **Before you start** * KnoxCall: an **Enterprise** plan, the **owner or admin** role, and at least one [domain verified for single sign-on](/sso/domains) — a domain you verified for *sending mail* is a different proof and does not admit a login. * Okta: an administrator who can create app integrations (Super Admin, or Application Administrator). **Configured KnoxCall before 8 September 2026?** Re-upload our SP metadata, or re-enter the values below, before you go further. KnoxCall now **signs AuthnRequests** (RSA-SHA256), publishes an **encryption** key, and publishes a **Single Logout** endpoint. An Okta app built from the older document has no signature certificate on file for us, cannot offer **Encrypted assertion**, and has no SLO endpoint of ours to call. None of that is visible from KnoxCall's side. See [the call-out on the overview page](/sso/overview) for the whole story. ## 1. Create the app integration **Applications → Applications → Create App Integration → SAML 2.0 → Next.** Name it (for example `KnoxCall`), optionally add a logo, and continue to **Configure SAML**. ## 2. Configure SAML Replace `acme` with your workspace slug throughout. The authoritative copy of every value below is your own metadata document at `https://acme.knoxcall.com/saml/acme/metadata` — it is public, so fetch it in a browser and read the values off it. | Okta field | Value | | - | - | | **Single sign-on URL** | `https://acme.knoxcall.com/saml/acme/acs` | | **Use this for Recipient URL and Destination URL** | checked | | **Audience URI (SP Entity ID)** | `https://acme.knoxcall.com/saml/acme/metadata` | | **Default RelayState** | leave empty | | **Name ID format** | the email-address format — KnoxCall's sign-in request names `urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress`, and Okta requires the two to match | | **Application username** | **Custom**, with the expression `user.getInternalProperty("id")` — the Okta user ID, a stable identifier | | **Response** / **Assertion Signature** | Signed | | **Signature Algorithm** | `RSA-SHA256` | KnoxCall's SP entity ID *is* its metadata URL. That is deliberate and it is what the metadata document says; do not shorten it to the origin. **Why the application username is the Okta user ID, not the email.** The application username is what Okta puts in the NameID (its SAML field reference describes the Name ID format as "The username format to send in the SAML Response"), and KnoxCall recognises a person by their NameID. An email-based username changes when the person is renamed — Okta's own documentation notes that it can update an app username automatically "when the email address changes" — and a NameID that changes gives the renamed person a second KnoxCall account. The Okta user ID is an identifier Okta assigns, not one made from the person's name or address, so a rename leaves it alone; Okta's Expression Language reference documents `user.getInternalProperty("id")` as returning the user's ID. The whole argument is in [choosing the NameID](/sso/overview#choosing-the-nameid). **Then check what Okta actually sends.** Okta documents that when a sign-in request includes a NameID policy, the application's Name ID format "must match it", which is why the format stays on the email-address format above. It does not document what it does with a username that is not an address under that format. So once step 4 is done, run a sign-in from the connection's **Test** tab in KnoxCall and read the **NameID** in the verdict: an id like `00u…` means you are done; an address means the connection will keep showing its NameID notice, and it will be right to. These are the `default` connection's values — the connection every workspace starts with, and the one the `/saml//…` alias resolves to. If Okta is a *second* identity provider for this workspace, it gets its own connection with its own handle, and its values are `/sso///{metadata,acs,login,slo}` — for a handle of `okta`, `https://acme.knoxcall.com/sso/acme/okta/acs` and its three siblings. Open that connection under **Settings → Single sign-on** and its **Certificates** tab lists all five, each with a copy button. See [more than one identity provider](/sso/overview#more-than-one-identity-provider). ### Attribute statements KnoxCall reads a plain email and display name and nothing else. Two statements are enough: | Name | Name format | Value | | - | - | - | | `email` | Basic | `user.email` | | `displayName` | Basic | `user.displayName` | Both names are on KnoxCall's default list, so no configuration is needed on the KnoxCall side to read them. If your Okta profile carries the address somewhere other than `user.email`, keep the statement named `email` and change the value — that is easier than naming an attribute on the KnoxCall connection. **The `email` statement is required.** With the NameID set to the Okta user ID, it is the only place KnoxCall finds the person's address — without it nobody can be admitted, because admission is decided by the address's domain. ### Group attribute statements (optional) Only needed if you want group membership to decide whether a new person lands as **member** or **readonly**. | Name | Name format | Filter | | - | - | - | | `groups` | Basic | `Matches regex` · `.*`, or `Starts with` · a prefix | `groups` is one of the attribute names KnoxCall checks without being told to. Prefer a narrow filter to `.*`: Okta emits every matching group name on every assertion, and an assertion is a POST body a browser has to carry. Whatever Okta emits — group **names**, here — is what you type as the keys of the group map on the KnoxCall connection. The map's values are bounded: a group mapped to anything but `member` or `readonly` is refused at save time, because *"…an IdP group may only map to `member or readonly`…"* — those two and no others. Owner and admin are granted by a human on the team page and by nothing else. ## 3. Advanced settings (optional, and both worth doing) **Assertion Encryption.** Set **Assertion Encryption** to `Encrypted`, then upload KnoxCall's **encryption** certificate. Take it from the `use="encryption"` KeyDescriptor in our metadata document — it is the same certificate as the signing one, published twice on purpose, because an IdP reads one descriptor for each job. KnoxCall accepts encrypted and plain assertions alike, so this is your call. **Signature Certificate.** Upload the certificate from the `use="signing"` KeyDescriptor so Okta can verify the AuthnRequests KnoxCall signs. KnoxCall signs every one; Okta simply ignores the signature until you give it the key to check it against. **Single Logout.** Tick **Enable Single Logout**, then: | Okta field | Value | | - | - | | **Single Logout URL** | `https://acme.knoxcall.com/saml/acme/slo` | | **SP Issuer** | `https://acme.knoxcall.com/saml/acme/metadata` | | **Signature Certificate** | KnoxCall's signing certificate, as above | **Okta uses the label “Single Logout URL” for both directions, and they are different values.** The one in the table above is OURS, which you give to Okta. The one below is OKTA'S, which you give to KnoxCall. Pasting ours into KnoxCall — or Okta's into Okta — saves without complaint and never works. That is the IdP→SP direction. For the other direction — signing out of KnoxCall also signing out of Okta — put Okta's own SLO endpoint (`https://.okta.com/app///slo/saml`, shown in Okta's SAML setup instructions) into the KnoxCall connection's **Single Logout URL**. It must be https; KnoxCall refuses anything else at save time, because a `LogoutRequest` carries the person's email address as its NameID and the browser is redirected to that URL. ## 4. Take the Okta values back to KnoxCall Two routes: import Okta's federation metadata, or type the values by hand. The import is the one that cannot be mis-transcribed. ### Import the federation metadata **Applications → your app → Sign On → SAML Setup Instructions** publishes Okta's own **Identity Provider metadata** — one document carrying the issuer, the sign-on endpoint and every signing certificate. Hand KnoxCall that link, or the document itself: ```http theme={"dark"} POST /admin/saml/connections//metadata { "url": "" } ``` ```http theme={"dark"} POST /admin/saml/connections//metadata { "xml": "" } ``` The call **previews by default** and writes nothing until you send it again with `"apply": true`. What the preview reports, what an import never touches, and every refusal it can answer with are on the [overview](/sso/overview#importing-your-idps-metadata). Like the metadata you handed Okta, it is a **snapshot**: nothing re-reads that URL on a schedule, so run the import again when Okta rolls the app's signing certificate. ### Or type the values **Applications → your app → Sign On → SAML Setup Instructions** (or **View SAML setup instructions**) gives you three things. In KnoxCall, **Settings → Single sign-on** → your connection: | KnoxCall field | Where it is | Okta value | | - | - | - | | **IdP sign-in URL** | Connection tab | **Identity Provider Single Sign-On URL** (`https://.okta.com/app///sso/saml`) | | **IdP entity ID (optional)** | Connection tab | **Identity Provider Issuer** (`http://www.okta.com/`) | | **Single Logout URL (optional)** | Connection tab | Okta's own SLO endpoint (`https://.okta.com/app///slo/saml`), once you have enabled Single Logout in step 3 | | **Only admit specific domains** | Connection tab | leave it off to admit every domain you have proven for single sign-on; tick it to narrow to a list | | **Create accounts automatically on first sign-in** | Connection tab | on, unless you invite everyone by hand | | **Default role** | Attributes & groups tab | `member` or `readonly` | | **IdP signing certificate** | Certificates tab → *Add a certificate* | the **X.509 Certificate** shown there, with or without the BEGIN/END lines | Okta's issuer really does begin `http://www.okta.com/`. That is a SAML **entity ID** — a name, not an address; nothing dereferences it, and KnoxCall stores it as given. The two fields that *are* dereferenced, the sign-in URL and the single-logout URL, must both be https and are refused otherwise. Saving needs a re-authentication in the last five minutes; that is expected, and it is the same bar as changing a password. ## 5. Assign people, then test **Assignments → Assign** — to people, or to Okta groups. Okta refuses sign-in to anyone unassigned before KnoxCall ever sees a request. Test with one account: open `https://acme.knoxcall.com/saml/acme/login`, sign in, and check the person lands in the workspace with the role you expected. If it fails, the login page names the cause — every message and code is in [troubleshooting](/sso/troubleshooting). ## The Okta dashboard tile Clicking KnoxCall in the Okta End-User Dashboard is an **IdP-initiated** sign-in, and it is off unless you turn it on. With it off the person is bounced back to the login page with `saml_idp_initiated_disabled`. See [IdP-initiated sign-in](/sso/overview#idp-initiated-sign-in) for how to switch it on and what it costs. ## Rotating the Okta certificate Okta's app signing certificate expires, and Okta lets you generate the next one before the current one goes. Rotate **add-first**: 1. In Okta, **Sign On → SAML Signing Certificates → Generate new certificate**. Leave the current one active. 2. Copy the new certificate into the KnoxCall connection. Pasting a certificate **adds** it — a connection can hold several active certificates and an assertion signed by any of them verifies. 3. Activate the new certificate in Okta. 4. Retire the old certificate in KnoxCall. Doing it in that order means there is no instant at which the certificate Okta is signing with is not one KnoxCall trusts. Retiring the only active certificate is refused outright: *"This is the connection's only active IdP certificate. Add the new certificate first, then retire this one."* # SAML SSO with OneLogin Source: https://docs.knoxcall.com/sso/onelogin Connect OneLogin to KnoxCall over SAML 2.0 — the SAML Custom Connector, the ACS URL Validator regex, the NameID, parameters and role claims, encrypted assertions, Single Logout and certificate rotation, with the exact values to paste. # SAML SSO with OneLogin OneLogin federates through an application built on the **SAML Custom Connector (Advanced)**. There is no KnoxCall entry in the OneLogin catalogue, so you build your own — which is fine, because everything KnoxCall needs is four URLs and a certificate. **Before you start** * KnoxCall: an **Enterprise** plan, the **owner or admin** role, and at least one [domain verified for single sign-on](/sso/domains) — a domain you verified for *sending mail* is a different proof and does not admit a login. * OneLogin: an administrator who can create apps and edit certificates (Super User, or an Account Owner). **Configured KnoxCall before 8 September 2026?** Re-enter the values below, or re-upload our SP metadata. KnoxCall now **signs AuthnRequests** (RSA-SHA256), publishes an **encryption** key so an IdP can encrypt assertions to us, and publishes a **Single Logout** endpoint. A OneLogin app built from the older document has no signature certificate of ours on file, cannot be given our encryption certificate, and has no SLO endpoint of ours to call — none of which is visible from KnoxCall's side. See [the call-out on the overview page](/sso/overview) for the whole story. ## 1. Create the app **OneLogin admin console → Applications → Applications → Add App.** Search for **SAML Custom Connector (Advanced)** and select it. Name it (for example `KnoxCall`), add an icon if you like, and save. Saving creates the app and opens its tabs: **Info**, **Configuration**, **Parameters**, **Rules**, **SSO**, **Access**, **Users**, **Privileges**. ## 2. Configuration Replace `acme` with your workspace slug throughout. The authoritative copy of every KnoxCall value below is your own metadata document at `https://acme.knoxcall.com/saml/acme/metadata` — it is public, so fetch it in a browser and read the values off it rather than assembling them by hand. | OneLogin field | Value | | - | - | | **Audience (EntityID)** | `https://acme.knoxcall.com/saml/acme/metadata` | | **Recipient** | `https://acme.knoxcall.com/saml/acme/acs` | | **ACS (Consumer) URL Validator** | `^https:\/\/acme\.knoxcall\.com\/saml\/acme\/acs$` | | **ACS (Consumer) URL** | `https://acme.knoxcall.com/saml/acme/acs` | | **Single Logout URL** | `https://acme.knoxcall.com/saml/acme/slo` | | **Login URL** | `https://acme.knoxcall.com/saml/acme/login` | | **SAML initiator** | Service Provider | | **SAML nameID format** | `urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress` | | **SAML signature element** | Assertion | | **SAML signature algorithm** | SHA-256 | KnoxCall's SP entity ID *is* its metadata URL. That is deliberate and it is what the metadata document says; do not shorten it to the origin, or OneLogin's audience will not match the one KnoxCall enforces and every assertion will be rejected. **The ACS (Consumer) URL Validator is a regular expression, not a URL** — and it is the field this integration gets wrong most often. Pasting the ACS URL into it unescaped leaves every `.` as "any character", which matches hosts you do not own; leaving out the `^` and `$` anchors turns it into a substring test. Escape the dots and the slashes and anchor both ends, as in the table above, and change it whenever the ACS URL changes. **Check the SAML signature algorithm.** Older OneLogin connectors default to **SHA-1**, which KnoxCall does not accept. Set it to **SHA-256** on this tab before you test, or every sign-in fails signature verification with no clue as to why on the OneLogin side. These are the `default` connection's values — the connection every workspace starts with, and the one the `/saml//…` alias resolves to. If OneLogin is a *second* identity provider for this workspace, it gets its own connection with its own handle, and its values are `/sso///{metadata,acs,login,slo}` — for a handle of `onelogin`, `https://acme.knoxcall.com/sso/acme/onelogin/acs` and its three siblings. Open that connection under **Settings → Single sign-on** and its **Certificates** tab lists all five, each with a copy button. See [more than one identity provider](/sso/overview#more-than-one-identity-provider). Setting **Login URL** to the KnoxCall login endpoint makes the OneLogin portal tile an ordinary **SP-initiated** sign-in, which works with IdP-initiated login switched off. Leave it blank and the tile produces an unsolicited assertion instead, which KnoxCall refuses with `saml_idp_initiated_disabled` unless you have deliberately opted in — see [IdP-initiated sign-in](/sso/overview#idp-initiated-sign-in). ## 3. Parameters The **Parameters** tab decides what the assertion carries. It starts with one row, `NameID value`. ### The NameID should be a stable identifier Open the **NameID value** row. OneLogin's default value is **Email**, and an email changes when a person is renamed — which gives them a second KnoxCall account, because KnoxCall recognises people by their NameID ([choosing the NameID](/sso/overview#choosing-the-nameid)). Change the value to **OneLogin ID**: a number OneLogin assigns, not made from the person's name or address, so a rename leaves it alone. Keep **SAML nameID format** on the email-address format from step 2 — that is the format KnoxCall's sign-in request names, and OneLogin's own value has to sit under it. Once step 5 is done, run a sign-in from the connection's **Test** tab in KnoxCall and read the **NameID** in the verdict: a number means you are done; an address means the connection will keep showing its NameID notice, and it will be right to. ### The two attributes KnoxCall reads Add both with **+** on the Parameters tab, ticking **Include in SAML assertion** for each — a parameter without that flag is configured and not sent, which is the other common way this integration silently does nothing: | Field name | Value | Include in SAML assertion | | - | - | - | | `email` | Email | ticked | | `displayName` | First Name + Last Name, or Display Name if your directory carries one | ticked | Both field names are on KnoxCall's default list, so nothing has to be named on the KnoxCall side to read them. **The `email` parameter is required.** With the NameID set to the OneLogin ID, it is the only place KnoxCall finds the person's address — and admission is decided by that address's domain. ### Roles as groups (optional) Only needed if you want group membership to decide whether a new person lands as **member** or **readonly**. Add one more parameter, field name `groups`, value **User Roles**, with **Include in SAML assertion** ticked. `groups` is one of the attribute names KnoxCall checks without being told to, and OneLogin emits one value per role the person holds. Whatever OneLogin emits — role **names**, here — is what you type as the keys of the group map on the KnoxCall connection. The map's values are bounded: a group mapped to anything but `member` or `readonly` is refused at save time, because *"…an IdP group may only map to `member or readonly`…"* — those two and no others. Owner and admin are granted by a human on the team page and by nothing else. ## 4. Encrypted assertions (optional) On the **Configuration** tab, tick **Encrypt assertion** and give OneLogin the certificate from the `use="encryption"` KeyDescriptor in KnoxCall's metadata document. It is the same certificate as the signing one, published twice on purpose, because an IdP reads one descriptor for each job. KnoxCall accepts encrypted and plain assertions alike and both take exactly the same path, so this is your call, not ours — and an app configured before 8 September 2026 has no encryption key of ours to use until our metadata is read again. ## 5. Take the OneLogin values back to KnoxCall Two routes: import OneLogin's metadata, or type the values by hand. The import is the one that cannot be mis-transcribed. ### Import the federation metadata The app's **More Actions → SAML Metadata** publishes OneLogin's own metadata — one document carrying the issuer, the sign-on endpoint, the logout endpoint and the signing certificate, at `https://app.onelogin.com/saml/metadata/`. Hand KnoxCall that link, or the document itself: ```http theme={"dark"} POST /admin/saml/connections//metadata { "url": "https://app.onelogin.com/saml/metadata/" } ``` ```http theme={"dark"} POST /admin/saml/connections//metadata { "xml": "" } ``` The call **previews by default** and writes nothing until you send it again with `"apply": true`. What the preview reports, what an import never touches, and every refusal it can answer with are on the [overview](/sso/overview#importing-your-idps-metadata). Like the metadata you gave OneLogin, it is a **snapshot**: nothing re-reads that URL on a schedule, so run the import again when you move the app to a new certificate. ### Or type the values The app's **SSO** tab carries all of them. In KnoxCall, **Settings → Single sign-on** → your connection: | KnoxCall field | Where it is | OneLogin value | | - | - | - | | **IdP sign-in URL** | Connection tab | **SAML 2.0 Endpoint (HTTP)** (`https://.onelogin.com/trust/saml2/http-post/sso/`) | | **IdP entity ID (optional)** | Connection tab | **Issuer URL** (`https://app.onelogin.com/saml/metadata/`) | | **Single Logout URL (optional)** | Connection tab | **SLO Endpoint (HTTP)** (`https://.onelogin.com/trust/saml2/http-redirect/slo/`) | | **Only admit specific domains** | Connection tab | leave it off to admit every domain you have proven for single sign-on; tick it to narrow to a list | | **Create accounts automatically on first sign-in** | Connection tab | on, unless you invite everyone by hand | | **Default role** | Attributes & groups tab | `member` or `readonly` | | **IdP signing certificate** | Certificates tab → *Add a certificate* | **X.509 Certificate** → *View Details*, copied with or without the BEGIN/END lines | Saving needs a re-authentication in the last five minutes; that is expected, and it is the same bar as changing a password. ## 6. Single Logout OneLogin's SLO endpoint goes into the KnoxCall connection, as in the table above, and KnoxCall's goes into **Single Logout URL** on the Configuration tab — that is both directions, and step 2 has already done half of it. Leave the KnoxCall field empty if you do not want the SP-initiated direction. Empty means "this connection does not do SP-initiated Single Logout" and it never falls back to the sign-in URL. The KnoxCall field must be https; KnoxCall refuses anything else at save time, because a `LogoutRequest` carries the person's email address as its NameID and the browser is redirected to that URL. Either way, signing out of KnoxCall always ends the KnoxCall session — never conditionally on the IdP leg. The full behaviour is in [Single Logout](/sso/overview#single-logout). ## 7. Assign people, then test **Access** on the app → add the roles that should have KnoxCall, or assign individual users from **Users**. OneLogin refuses sign-in to anyone unassigned before KnoxCall ever sees a request. Test with one account: open `https://acme.knoxcall.com/saml/acme/login`, sign in, and check the person lands in the workspace with the role you expected. If it fails, the login page names the cause — every message and code is in [troubleshooting](/sso/troubleshooting). ## Rotating the OneLogin certificate **A OneLogin certificate belongs to the account, not to this app** — it is created under **Settings → Certificates** and each app selects one from a dropdown. So a rotation is: create the new certificate, teach KnoxCall about it, then point the app at it. Rotate **add-first**: 1. In OneLogin, **Settings → Certificates → New**, and generate the replacement. Do not change the app yet. 2. Copy the new certificate into the KnoxCall connection. Pasting a certificate **adds** it — a connection can hold several active certificates and an assertion signed by any of them verifies. 3. On the app's **SSO** tab, select the new certificate. 4. Retire the old certificate in KnoxCall. Doing it in that order means there is no instant at which the certificate OneLogin is signing with is not one KnoxCall trusts. Retiring the only active certificate is refused outright: *"This is the connection's only active IdP certificate. Add the new certificate first, then retire this one."* Because the certificate is account-wide, step 3 is per app: rotating it here does not move your other OneLogin apps, and switching them over is a separate decision for each. # Single Sign-On (SAML) Source: https://docs.knoxcall.com/sso/overview Federate KnoxCall with Entra ID, Okta, Google Workspace, JumpCloud, Auth0, OneLogin or PingOne over SAML 2.0 — what KnoxCall publishes, which claims it reads, how domains are proven, and how Single Logout works. # Single Sign-On (SAML) KnoxCall is a SAML 2.0 **service provider**. Your identity provider authenticates the person; KnoxCall accepts the signed assertion, links or creates the account, and issues a session scoped to the workspace whose IdP asserted. Set-up is the same four moves everywhere: 1. **Prove the email domains** your IdP will assert. See [Verifying a domain](/sso/domains). 2. **Give your IdP the KnoxCall URLs** below (or upload our metadata document). 3. **Bring the IdP's sign-in URL and signing certificate back** to KnoxCall. 4. **Test with one account** before you assign the app to everyone. Per-provider walkthroughs: [Microsoft Entra ID](/sso/entra-id) · [Okta](/sso/okta) · [Google Workspace](/sso/google-workspace) · [JumpCloud](/sso/jumpcloud) · [Auth0](/sso/auth0) · [OneLogin](/sso/onelogin) · [PingOne](/sso/pingone). **Running something else?** Any SAML 2.0 identity provider works — ADFS, Shibboleth, Keycloak, PingFederate, a vendor we have not met. There is no walkthrough for yours, so take the generic route: give it our metadata document, import its federation metadata back, and read the two sections that are provider-independent — [choosing the NameID](#choosing-the-nameid) and [what an assertion may and may not do](#roles-what-an-assertion-may-and-may-not-do). The [PingFederate notes](/sso/pingone#pingfederate) are the closest thing to a template for a self-hosted server. **If you configured KnoxCall before 8 September 2026, re-upload our SP metadata to your IdP.** The document changed in three ways that an IdP only learns about by re-reading it: KnoxCall now **signs AuthnRequests**, publishes an **encryption** key so your IdP can encrypt assertions to us, and publishes a **Single Logout** endpoint. An app configured from the old document will not offer token/assertion encryption at all, may reject our now-signed requests as unexpectedly signed, and cannot have Single Logout switched on — none of which is visible from KnoxCall's side. Re-uploading the metadata (or re-importing the metadata URL, which is a snapshot in Entra, Okta and JumpCloud alike) is the whole fix. ## What KnoxCall publishes Replace `acme` with your workspace slug. Every URL is on your own workspace origin. | Field | Value | | - | - | | **SP entity ID** (audience) | `https://acme.knoxcall.com/saml/acme/metadata` | | **Metadata document** | `https://acme.knoxcall.com/saml/acme/metadata` | | **ACS / Sign-on URL** (Reply URL) | `https://acme.knoxcall.com/saml/acme/acs` — HTTP-POST binding | | **Login URL** (SP-initiated start) | `https://acme.knoxcall.com/saml/acme/login` | | **Single Logout URL** | `https://acme.knoxcall.com/saml/acme/slo` — HTTP-POST and HTTP-Redirect | The dashboard shows the ACS, metadata and login URLs ready to copy under **Settings → Single sign-on**. The metadata document is public and unauthenticated: you can fetch it before anything is configured, which is what makes it usable as step one. **Your metadata document is the authoritative copy of all five values.** Fetch it and read them off it rather than assembling them by hand — a self-hosted or single-tenant KnoxCall serves the same endpoints on a different origin, and the entity ID your IdP is configured with has to be byte-identical to the audience KnoxCall enforces. These are the `default` connection's URLs — the connection every workspace starts with — and they are permanent: metadata you upload today never needs replacing, and adding a second identity provider later does not disturb them. The `/saml//…` alias **is** that connection: it resolves there whenever a connection named `default` is active, so its entity ID, its ACS and the audience it enforces are the alias's, byte for byte. A connection with any other handle publishes its own SP identity at `/sso///…` instead. See [more than one identity provider](#more-than-one-identity-provider). ## Importing your IdP's metadata The exchange runs both ways. Your IdP publishes a SAML 2.0 metadata document of its own, carrying the entity ID, the sign-on endpoints, the logout endpoint and every signing certificate at once — and KnoxCall will read it, so that none of those has to be transcribed by hand. A truncated certificate and an entry point copied from the wrong tenant are the two commonest reasons a first login fails. Hand KnoxCall the document itself, or the address it is published at: ```http theme={"dark"} POST /admin/saml/connections//metadata { "xml": "" } ``` ```http theme={"dark"} POST /admin/saml/connections//metadata { "url": "https://idp.example.com/…/federationmetadata.xml" } ``` One or the other, never both — *"Send either — the document itself — or , where to fetch it from — but not both."* The `` is the connection's id, which is in the address bar when you have the connection open. **The call previews by default; nothing is written until you send it again with `"apply": true`.** The preview reports: * **entry point and entity ID** — what would be stored, plus every HTTP-Redirect and HTTP-POST `SingleSignOnService` endpoint the document publishes (redirect first, which is the one KnoxCall would use); * **the Single Logout endpoints**, same shape; * **the certificates**, each with its fingerprint, its validity dates, whether it has expired, and a state: `new`, `already_active`, or `retired` — that last one matters, because applying the import brings a certificate you deliberately retired back into service, and the preview is where you find that out; * **the NameID formats** the document offers. A format is a label, not the value: offering the email-address format is expected — it is the one KnoxCall's sign-in request names, and the provider guides tell you to keep it — and it says nothing about whether the NameID *value* is the person's login address, which is what [choosing the NameID](#choosing-the-nameid) is about. So when the document offers it and nothing has signed in through the connection yet, the preview carries a notice asking you to **run a test sign-in and read the NameID**, not one saying the NameID is an address. Once people have signed in, the notice is decided by the NameIDs they actually sent, exactly as on the connection's page; * **changes** — field by field, the stored value against the incoming one. An import never clears what the document does not carry: an export made before you switched Single Logout on has no `SingleLogoutService`, and KnoxCall leaves your existing SLO URL exactly where it was rather than reading the absence as "turn it off". It never retires a certificate either — rotation stays add-first — and it does not touch the domain list. What the document must be: a single `` in the SAML 2.0 metadata namespace, carrying an ``. A whole-federation `` is refused rather than guessed at, and so is the SP half — that is the document KnoxCall gives *you*. Every refusal, with its code, is in [refusals from the IdP metadata import](/sso/troubleshooting#refusals-from-the-idp-metadata-import). **The import is an API call today.** The connection's **Certificates** tab does not offer it yet — its **Add a certificate** box is the by-hand path. KnoxCall does store the URL you imported from, but **nothing re-reads it on a schedule**: when your IdP rolls its signing certificate, run the import again, or paste the new certificate. That is [rotation, with overlap](#certificate-rotation-with-overlap), unchanged. ## What KnoxCall requires of the assertion | Property | Value | | - | - | | NameID format requested | `urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress` — a format, not the value; the value should still be a stable identifier (see [choosing the NameID](#choosing-the-nameid)) | | Assertions | must be **signed** | | Assertion lifetime | **5 minutes** maximum, whatever the IdP's own conditions say | | Replay | each assertion ID is accepted **once** — a second POST of the same assertion is refused with `saml_replay` | | AuthnRequests | **signed** by KnoxCall, RSA-SHA256 with SHA-256 digests | | Encrypted assertions | **accepted, never required** — encryption is your app's choice, and a plain signed assertion takes exactly the same path | | Binding | HTTP-POST to the ACS | Our metadata carries the SP certificate twice, as `use="signing"` (so your IdP can verify our AuthnRequests) and as `use="encryption"` (so your IdP can offer assertion/token encryption at all). It is one certificate in both roles, which is what every IdP's upload flow expects. ## The claims KnoxCall reads **Email.** The address decides whether a person may be admitted at all — its domain must be one you have [verified](/sso/domains) — and it is how KnoxCall finds an existing account the first time someone signs in. After that, the person is recognised by their **NameID** (see [choosing the NameID](#choosing-the-nameid)). KnoxCall reads the address, in order, from: the attribute you named on the connection, then the IdP's own `email` field, then the first of these attributes that is present — ```text theme={"dark"} http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress urn:oid:0.9.2342.19200300.100.1.3 (mail) urn:oid:1.2.840.113549.1.9.1 (emailAddress) email mail emailAddress ``` — and finally the **NameID** itself when it looks like an address. That last step is a fallback, not a recommendation: send the address as its own attribute, and make the NameID a stable identifier instead. Every provider guide here maps an `email` attribute for exactly that reason. **Display name.** Same shape: the attribute you named, then the IdP's `displayName`, then `.../claims/displayname`, `.../claims/name`, `urn:oid:2.16.840.1.113730.3.1.241`, `displayName`, `name`, `cn`. Absent, the local part of the email is used. **Groups.** KnoxCall reads the attribute you name in the connection's `attribute_map.groups`, and otherwise the first of — ```text theme={"dark"} http://schemas.microsoft.com/ws/2008/06/identity/claims/groups (Entra ID) http://schemas.xmlsoap.org/claims/Group groups memberOf member_of ``` Groups only matter if you map them (below). Everything else in the assertion is ignored. ## Choosing the NameID KnoxCall remembers each person by the **NameID** your identity provider sends, per connection, and asks that question before it looks at the email address. That is what lets someone change their address *inside KnoxCall* without ending up with two accounts. It only protects against a rename *in your directory* if the NameID does not change when the person is renamed. If the NameID **is** the login address — the default in several identity providers — then renaming someone in your directory (a legal name change, a domain migration) changes the very value KnoxCall recognises them by. Their next sign-in looks like a stranger: KnoxCall creates a **second account and a second membership for the same person**, with its own audit trail and its own grants, and removing one of them does not remove the other. KnoxCall does not merge the two automatically, because merging on anything weaker than the NameID risks merging two different people. So: **make the NameID a stable directory identifier** — one that stays the same when the person is renamed — and send the email address as its own attribute. Each provider guide shows where: * [Microsoft Entra ID](/sso/entra-id#3-configure-claims) — the `objectid` source attribute. * [Okta](/sso/okta#2-configure-saml) — a Custom application username holding the Okta user ID. * [Google Workspace](/sso/google-workspace#3-service-provider-details) and [JumpCloud](/sso/jumpcloud#2-service-provider-details) — a custom attribute holding a value that does not change. * [OneLogin](/sso/onelogin#3-parameters) — the OneLogin ID, on the NameID value parameter. * [PingOne](/sso/pingone#2-attribute-mappings) — the User ID, on the **saml\_subject** mapping. * [Auth0](/sso/auth0#the-nameid-auth0-already-gets-this-right-and-an-edit-can-break-it) — already the Auth0 user id by default; the guide names the two edits that lose it. People who have already signed in keep the account they have; only the identifier that future sign-ins carry changes. Make the change before your next rename, not after it. **KnoxCall tells you when a connection is set up the way that forks.** The connection's page shows a notice when the NameIDs it has admitted are addresses, and a test sign-in shows it when the assertion it just checked carries one. A metadata import sees no NameID at all — only the formats your provider's document advertises — so before anyone has signed in it says only what it can: if the document offers the email-address format, it asks you to run a test sign-in and read the NameID, because that format is expected and does not tell anyone what the value is. It is a warning, never a refusal — sign-ins keep working, and a workspace may run this configuration knowingly. It clears as people sign in again with a stable identifier. **Prove it with a test sign-in.** KnoxCall's sign-in request names the email-address NameID format (the table above), and providers treat a requested format differently: Microsoft documents that Entra ID "honors the format in the request" when the request names one, and Okta documents that when the request includes a NameID policy, the application's Name ID format "must match it". Neither documents exactly what value accompanies that format once you have pointed the NameID at a stable identifier. So after changing it, run a sign-in from the connection's Test tab and read the NameID the verdict reports — that request carries the same format as a real sign-in, so what it shows is what your people will send. If it still shows an address, the notice stays up, and it is telling you the truth. ## Roles: what an assertion may and may not do A SAML assertion is signed by **your** IdP, so as far as KnoxCall is concerned it is an unauthenticated door. It may confer **member** or **readonly** and nothing else: * The connection's **default role** is what a new member gets. Saving anything else is refused: *"Default role must be one of `member, readonly`. Just-in-time provisioning may never create an owner or an admin — elevate members on the team page."* * A **group → role map** overrides the default when the assertion carries a mapped group; if several map, the strongest wins. The same bound applies — *"Group `'Engineering'` maps to `'owner'`; an IdP group may only map to `member or readonly` — an IdP-asserted login may never mint an administrator (elevate members on the team page)."* * **Owner and admin are always granted by a human, on the team page.** No IdP configuration, group name or attribute can produce one. Group mapping decides the role a person gets when they are **first** added to the workspace. Moving someone between groups in your IdP later does not currently change their KnoxCall role — change it on the team page. Removing their access in the IdP does stop them signing in, and [Single Logout](#single-logout) ends the sessions they already hold. ## Domain proof, in one paragraph Your IdP signs its own assertions, so it could assert any address at all. KnoxCall therefore only lets an assertion claim addresses at domains **you have proven you control**, by publishing a TXT record we give you. Proving `acme.com` covers `eu.acme.com`; proving a subdomain never covers its parent. Without a proven domain, just-in-time provisioning is refused outright and a pre-existing account that is not already a member is refused too. **Only an SSO domain proof admits a login** — a domain you verified for *sending mail* is a different proof about a different question and does not count here. The full flow, the exact record and every refusal are in [Verifying a domain](/sso/domains). ## IdP-initiated sign-in Off unless you turn it on, per connection. * **SP-initiated** (the default): the person starts at KnoxCall, on `https://acme.knoxcall.com/saml/acme/login`. Bookmark it, or publish it as the sign-in link for your workspace — KnoxCall sends a signed AuthnRequest, and the assertion that comes back is bound to that request. * **IdP-initiated**: the person clicks a tile in the IdP's app launcher and an unsolicited assertion is POSTed to the ACS. It answers no request of ours, so anyone holding one can post it into a browser at a time of their choosing. That is why it is opt-in. With it off, an unsolicited assertion is refused with `saml_idp_initiated_disabled`. The control is the **Accept sign-ins started at the identity provider** checkbox, under *Who may sign in* on the connection's **Connection** tab (`allow_idp_initiated` on the API). Tick it and save. Use the page rather than the API for this. `PUT /admin/saml` and `PUT /admin/saml/connections/` are whole-object **replaces**, not patches: a field you leave out is reset to its default — `null` for most of them — rather than left alone. So send the whole connection, and send a real boolean, because the string `"true"` is refused: *"Allow IdP-initiated sign-in must be true or false."* A one-field body does not get you a patch, it gets you a refusal: `PUT /admin/saml` answers *"The IdP sign-in URL is required."*, and `PUT /admin/saml/connections/` answers *"Verified email domains are required — list at least one domain your company controls."* Four fields are exceptions — they keep whatever is stored when your body does not mention them — and they are not the same four on both routes: | Field | On `PUT /admin/saml/connections/` | On `PUT /admin/saml` | | - | - | - | | `provider` | kept when absent; send a value to change it | kept when absent; send a value to change it | | `attribute_map` | kept when absent; send `null` or `{}` to clear it | this route does not carry the field at all, so it is always kept and can never be set here | | `idp_slo_url` | replaced like any other field — omit it and it is cleared | kept when absent; send `null` to clear it | | `idp_metadata_url` | replaced like any other field — omit it and it is cleared | kept when absent; send `null` to clear it | On the IdP-initiated flow only, KnoxCall honours **RelayState** as a deep link — but only as a relative path inside the app. An absolute URL is dropped and the person lands on the default page. ## More than one identity provider A workspace may run **several active connections at once** — one per identity provider, or one per business unit that brought its own. They do not authenticate for each other: each connection has its own SP identity, and an assertion is only ever accepted by the connection whose audience it names. The first connection every workspace has is `default`, and it owns the `/saml//…` URLs above. **Every other connection publishes at its own path**, built from its **Handle** on the Connection tab: | Field | Value, for a connection whose handle is `entra` | | - | - | | **SP entity ID** (audience) | `https://acme.knoxcall.com/sso/acme/entra/metadata` | | **Metadata document** | `https://acme.knoxcall.com/sso/acme/entra/metadata` | | **ACS / Sign-on URL** | `https://acme.knoxcall.com/sso/acme/entra/acs` | | **Login URL** | `https://acme.knoxcall.com/sso/acme/entra/login` | | **Single Logout URL** | `https://acme.knoxcall.com/sso/acme/entra/slo` | Two things worth knowing before you add the second one: * **The `/saml//…` alias is the `default` connection.** It resolves to the connection named `default` whenever one is active, so the first IdP keeps working exactly as it did — that connection's own URLs *are* the alias, and nothing you configured before adding the second IdP has to change. The alias answers `409` with `saml_connection_ambiguous` in one case only: **two or more connections are active and none of them is named `default`.** Then it will not guess, and each connection has to be reached at its own `/sso///{metadata,acs,login,slo}`. The practical rule follows from that: keep your first identity provider on the connection named `default`, and give the second one its own handle and its own URLs. * **The dashboard shows each connection's own URLs.** Open the connection under **Settings → Single sign-on** and its **Certificates** tab lists five values for *that* connection — SP entity ID, sign-on / ACS URL, login URL, Single Logout URL and metadata URL — each with a copy button, above a link that opens its metadata document. A connection whose handle is not `default` shows its `/sso/…` set there, not the alias. The metadata document stays authoritative for all five: `https://acme.knoxcall.com/sso/acme//metadata` is public, so a browser will do, and `GET /admin/saml/connections//sp-metadata` returns the same document as a download for an authenticated owner or admin — which is the reason to prefer uploading it to your IdP over typing anything. ### If your IdP was configured with an identifier of its own The connection form has an optional **SP entity ID**. Leave it empty and KnoxCall uses this connection's own metadata URL, which is what the metadata document publishes and is unique by construction. Set it only when your identity provider already knows KnoxCall by another identifier — and then two rules apply, because that one string becomes both the issuer KnoxCall sends and the audience every assertion must name: * **It may not be a KnoxCall-shaped URL.** Any value whose path sits under `/saml/` or `/sso/`, on any host, is refused: *"That service-provider entity ID is shaped like one of ours: its path is under /saml/ or /sso/, which is the address family KnoxCall computes SP identities in…"* Otherwise a connection could claim the identity another connection's sign-on URL enforces, and an assertion minted for that one would be accepted here. * **It must be unique across every KnoxCall workspace**, not only yours: *"That service-provider entity ID is already in use. An SP entity ID is a name in one global namespace…"* The refusal deliberately names no other workspace or connection — you are told the string is taken and nothing more, so pick another identifier your IdP owns, or empty the field and take the computed one. ## Single Logout KnoxCall supports SLO in **both** directions at one URL per connection, on both bindings. For the `default` connection that is the alias, `https://acme.knoxcall.com/saml/acme/slo`; every other connection ends its own sessions at `https://acme.knoxcall.com/sso/acme//slo`. A logout message only ever ends the sessions the connection it arrived at authenticated. * **Your IdP ends the session.** A signed `LogoutRequest` naming the person's NameID (and, when present, the SessionIndex) ends the sessions **this workspace's** IdP authenticated — access tokens stop being accepted and the refresh family is revoked. Sessions the same person holds in a *different* workspace are untouched: that workspace's IdP and policy never participated. * **The person signs out of KnoxCall.** If their session was minted by SAML, KnoxCall answers the sign-out with a signed `LogoutRequest` aimed at your IdP's SLO endpoint. The KnoxCall session is revoked either way — never conditionally on the IdP leg. Three requirements: * Your IdP's SLO URL is a **connection setting** (`idp_slo_url`) and must be **https**. It is a real destination — a browser is redirected there carrying a NameID, which is usually a person's email address. Empty means "this connection does not do SP-initiated Single Logout"; it never falls back to the sign-in URL. * A redirect-binding logout message **must be signed**. An unsigned one is refused (`saml_logout_unsigned`) — otherwise anyone who could reach the URL could sign out any user whose email address they could guess. * Logout messages are accepted for **5 minutes** and **once** (`saml_logout_stale`, `saml_logout_replay`). Single Logout is deliberately **not** plan-gated: refusing to end a session because a subscription lapsed is the wrong direction of that control. ## Certificate rotation, with overlap Your IdP's signing certificate expires. Rotate it **add-first** — never retire-then-add. 1. **Add the new certificate while the old one is still active.** Paste it into **Settings → SSO →** your connection **→ Certificates → Add a certificate**. Pasting a certificate **adds** it; it does not replace the one already there. 2. **Check that both are listed as active.** The Certificates tab shows every certificate on the connection with its fingerprint and its expiry date. An assertion signed by *any* active certificate verifies, so while both are listed you are covered whichever key your IdP signs with. 3. **Switch your IdP over to signing with the new key.** In every provider documented here this is a setting on the application or the account, not something KnoxCall can trigger. In Auth0 the signing key is the **tenant's** and rotating it re-signs every SAML app in that tenant; in OneLogin and PingOne the certificate is the account's or the environment's and each app selects one, so switching is per app. 4. **Sign in once**, to confirm the new certificate is the one being used. 5. **Retire the old certificate.** It stops being able to verify an assertion or a logout message immediately; the row stays for the audit trail, with the time you retired it. There is no window in which sign-in is down, because at every step at least one active certificate matches the key your IdP is signing with. ### What happens if you retire the last one You cannot. The writer refuses it: *"This is the connection's only active IdP certificate. Add the new certificate first, then retire this one."* The same rule applies when you try to switch a connection on without one: *"An SSO connection cannot be activated without at least one active IdP certificate."* — and one with no sign-in URL: *"An SSO connection cannot be activated without an IdP entry point."* That refusal protects you from the obvious mistake, and only from that one. It cannot stop you from keeping a single active certificate that your IdP has already stopped using — which is why the expiry mail below exists. ### The expiry email Nothing re-reads your IdP's metadata on a schedule (see the note under [importing your IdP's metadata](#importing-your-idps-metadata)), so the certificate KnoxCall holds is the one you last gave it. What KnoxCall does watch is that certificate's own expiry date: it reads `not_after` on every active certificate of every active connection and emails the workspace's **owners and admins** at **30 days**, **14 days**, **3 days**, and again **once it has expired**. Each of those four fires once per certificate. Nobody has to be looking at the page. Two things the mail will tell you, because they lead to different actions: * **Another active certificate outlives this one.** Sign-in keeps working. Finish the rotation — confirm your IdP is signing with the newer certificate, then retire this one. * **This is the only active certificate.** Sign-in still works *today*, and here is the exact reason it will stop: KnoxCall does not reject an assertion because the signing certificate has expired — but your IdP will stop signing with that key when it rotates, and KnoxCall then has nothing to check the signature against. From that moment every SAML sign-in to the workspace fails, and the failure reads as a signature mismatch, not as an expiry. **The remedy is step 1 above: add the new certificate.** If your IdP has already rotated, get its current signing certificate — paste it into **Add a certificate**, or re-run [the metadata import](#importing-your-idps-metadata), which reads every signing certificate the document carries — add it, and sign-in resumes on the next attempt. There is nothing else to switch back on. If a certificate was uploaded before KnoxCall recorded expiry dates, its expiry is filled in the first time the check runs. A certificate KnoxCall cannot parse at all is reported to the operator rather than assumed healthy. ### Doing it over the API Adding a certificate out-of-band is `POST /admin/saml/connections//certificates`; retiring one is `DELETE /admin/saml/connections//certificates/`. Both need the **owner or admin** role and a re-authentication in the last 5 minutes, like every other write on this surface. ## Plan and permissions * **SSO is an Enterprise feature.** If the subscription lapses, **existing connections keep authenticating your members** — only configuration is refused: *"Single sign-on configuration requires a current Enterprise plan. Existing SSO connections keep authenticating your members; adding or changing one needs the subscription brought up to date."* * Configuring SSO needs the **owner or admin** role plus a re-authentication in the last 5 minutes. Anyone else is refused: *"Managing the SAML connection requires the owner or admin role — this row is the tenant's authentication policy: it names the identity provider allowed to assert who your users are, and the email domains whose accounts it may claim."* * KnoxCall staff **cannot** write your SSO configuration. It is refused at the same surface an operator would have to use. ## Rate limits and safety rails * The ACS and the SLO endpoint each accept **30 requests per minute** per (workspace, source address) pair, with separate budgets so a logout storm cannot spend your sign-in allowance. * The ACS is public, so refusals are deliberately uninformative to the caller; the detail is in your workspace's audit and security events. * The `/saml//…` alias resolves to the connection named `default`, however many others are active alongside it. It refuses to guess — `409`, `saml_connection_ambiguous` — only when two or more connections are active and **none** carries that name. The connection-scoped `/sso///…` URLs name one connection and are never ambiguous. ## When it does not work Every refusal message and every error code, with what to do about each: [SSO troubleshooting](/sso/troubleshooting). # SAML SSO with PingOne (and what differs on PingFederate) Source: https://docs.knoxcall.com/sso/pingone Connect PingOne to KnoxCall over SAML 2.0 — the SAML application, attribute mappings, group names, encrypted assertions, Single Logout and key-pair rotation, with the exact values to paste — plus what changes if you run PingFederate instead. # SAML SSO with PingOne (and what differs on PingFederate) **Establish which Ping product you have before you read any further — the two setups share almost nothing but a vendor.** * **PingOne** is Ping Identity's hosted identity service, administered at `console.pingone.com`, and its endpoints all live under `auth.pingone.com`. **This guide is PingOne**, and every value on it is a PingOne value. * **PingFederate** is the federation server you run yourself. Its endpoints are on *your* host, its entity ID is whatever you chose when you deployed it, and the object you create is an **SP connection**, not an application. Nothing on this page's paste table applies. Skip to [PingFederate](#pingfederate) at the bottom. **Before you start** * KnoxCall: an **Enterprise** plan, the **owner or admin** role, and at least one [domain verified for single sign-on](/sso/domains) — a domain you verified for *sending mail* is a different proof and does not admit a login. * PingOne: an administrator with the **Environment Admin** or **Identity Data Admin** role in the environment you are configuring, and the environment id, which is in the console URL. **Configured KnoxCall before 8 September 2026?** Re-import our SP metadata before you go further. KnoxCall now **signs AuthnRequests** (RSA-SHA256), publishes an **encryption** key so your IdP can encrypt assertions to us, and publishes a **Single Logout** endpoint. A PingOne application built from the older document has no verification certificate of ours on file — so **Enforce Signed AuthnRequest** will reject every sign-in if you switch it on — cannot be given our encryption certificate, and has no SLO endpoint of ours to call. See [the call-out on the overview page](/sso/overview) for the whole story. ## 1. Create the application **PingOne console → Applications → Applications → + → Application.** Name it (for example `KnoxCall`), choose **SAML Application**, and continue to **Configure SAML Connection**. PingOne offers two routes on that screen: **Import Metadata** (by URL or file) and **Manually Enter**. Take the import — it cannot be mis-transcribed. | PingOne field | Value | | - | - | | **Import from URL** | `https://acme.knoxcall.com/saml/acme/metadata` | Replace `acme` with your workspace slug. That document is public, so PingOne can fetch it, and it carries the entity ID, the ACS, the SLO endpoint, the signing certificate and the encryption certificate in one read. To type them instead, choose **Manually Enter**: | PingOne field | Value | | - | - | | **ACS URLs** | `https://acme.knoxcall.com/saml/acme/acs` | | **Entity ID** | `https://acme.knoxcall.com/saml/acme/metadata` | | **SLO Endpoint** | `https://acme.knoxcall.com/saml/acme/slo` | | **Target Application URL** | `https://acme.knoxcall.com/saml/acme/login` | | **Subject NameID Format** | `urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress` | | **Signing** | Sign Assertion | | **Signing Algorithm** | `RSA_SHA256` | KnoxCall's SP entity ID *is* its metadata URL. That is deliberate and it is what the metadata document says; do not shorten it to the origin, or PingOne's audience will not match the one KnoxCall enforces and every assertion will be rejected. These are the `default` connection's values — the connection every workspace starts with, and the one the `/saml//…` alias resolves to. If PingOne is a *second* identity provider for this workspace, it gets its own connection with its own handle, and its values are `/sso///{metadata,acs,login,slo}` — for a handle of `pingone`, `https://acme.knoxcall.com/sso/acme/pingone/acs` and its three siblings. Open that connection under **Settings → Single sign-on** and its **Certificates** tab lists all five, each with a copy button. See [more than one identity provider](/sso/overview#more-than-one-identity-provider). Setting **Target Application URL** to the KnoxCall login endpoint makes the PingOne application-portal tile an ordinary **SP-initiated** sign-in, which works with IdP-initiated login switched off. Leave it blank and the tile produces an unsolicited assertion instead, which KnoxCall refuses with `saml_idp_initiated_disabled` unless you have deliberately opted in — see [IdP-initiated sign-in](/sso/overview#idp-initiated-sign-in). ## 2. Attribute mappings Open the application's **Attribute Mappings** tab. PingOne starts with one mapping, **saml\_subject**, and that one decides the NameID. ### The NameID should be a stable identifier PingOne maps **saml\_subject** to **Email Address** by default. An email changes when a person is renamed, which gives them a second KnoxCall account, because KnoxCall recognises people by their NameID ([choosing the NameID](/sso/overview#choosing-the-nameid)). Change **saml\_subject** to **User ID** — the identifier PingOne assigns, a UUID, not made from the person's name or address, so a rename leaves it alone. Keep the **Subject NameID Format** on the email-address format from step 1, which is the format KnoxCall's sign-in request names. Once step 4 is done, run a sign-in from the connection's **Test** tab in KnoxCall and read the **NameID** in the verdict: a UUID means you are done; an address means the connection will keep showing its NameID notice, and it will be right to. ### The two attributes KnoxCall reads Add both, and mark `email` **Required** so a user with an empty address fails in PingOne rather than arriving here without one: | Attribute name | PingOne user attribute | | - | - | | `email` | Email Address | | `displayName` | Formatted name, or Display Name if your directory carries one | Both names are on KnoxCall's default list, so nothing has to be named on the KnoxCall side to read them. **The `email` attribute is required.** With the NameID set to the PingOne User ID, it is the only place KnoxCall finds the person's address — and admission is decided by that address's domain. ### Group names (optional) Only needed if you want group membership to decide whether a new person lands as **member** or **readonly**. Add one more mapping, attribute name `groups`, PingOne user attribute **Group Names**. `groups` is one of the names KnoxCall checks without being told to, and PingOne emits one value per group the person belongs to. Whatever PingOne emits — group **names**, here, rather than the group UUIDs — is what you type as the keys of the group map on the KnoxCall connection. The map's values are bounded: a group mapped to anything but `member` or `readonly` is refused at save time, because *"…an IdP group may only map to `member or readonly`…"* — those two and no others. Owner and admin are granted by a human on the team page and by nothing else. ## 3. Signing, encryption and signed requests (optional, and worth doing) All three live on the application's **Configuration** tab, under **SAML Settings**. * **Enforce Signed AuthnRequest.** KnoxCall signs every sign-in request it sends. Switch this on and give PingOne the certificate from the `use="signing"` KeyDescriptor in our metadata document as the **Verification Certificate**, and PingOne will check the signature rather than ignoring it. Switch it on *without* uploading the certificate and every sign-in fails. * **Enable Encryption.** Upload the certificate from the `use="encryption"` KeyDescriptor. It is the same certificate as the signing one, published twice on purpose, because an IdP reads one descriptor for each job. KnoxCall accepts encrypted and plain assertions alike and both take exactly the same path, so this is your call, not ours. * If you imported our metadata in step 1, PingOne already has both certificates and these are one toggle each. **Check the host.** PingOne's domain follows your environment's region, so the values below are only spelled `auth.pingone.com` in North America. Elsewhere the top-level domain itself changes — `auth.pingone.eu`, `auth.pingone.asia`, `auth.pingone.ca`, `auth.pingone.com.au` — and the example placeholders KnoxCall shows use the North American form. Copy each URL out of PingOne rather than typing what the placeholder suggests. ## 4. Take the PingOne values back to KnoxCall Two routes: import PingOne's metadata, or type the values by hand. The import is the one that cannot be mis-transcribed. ### Import the federation metadata The application's **Configuration** tab publishes **IDP Metadata URL** — one document carrying the issuer, the sign-on endpoint, the logout endpoint and the signing certificate, at `https://auth.pingone.com//saml20/metadata/`. Hand KnoxCall that link, or the document itself: ```http theme={"dark"} POST /admin/saml/connections//metadata { "url": "https://auth.pingone.com//saml20/metadata/" } ``` ```http theme={"dark"} POST /admin/saml/connections//metadata { "xml": "" } ``` The call **previews by default** and writes nothing until you send it again with `"apply": true`. What the preview reports, what an import never touches, and every refusal it can answer with are on the [overview](/sso/overview#importing-your-idps-metadata). Like the metadata you gave PingOne, it is a **snapshot**: nothing re-reads that URL on a schedule, so run the import again when you move the application to a new key pair. ### Or type the values Still on the **Configuration** tab, expand the connection details and download the signing certificate. In KnoxCall, **Settings → Single sign-on** → your connection: | KnoxCall field | Where it is | PingOne value | | - | - | - | | **IdP sign-in URL** | Connection tab | **Single Signon Service** (`https://auth.pingone.com//saml20/idp/sso`) | | **IdP entity ID (optional)** | Connection tab | **Issuer ID** (`https://auth.pingone.com/`) | | **Single Logout URL (optional)** | Connection tab | **Single Logout Service** (`https://auth.pingone.com//saml20/idp/slo`) | | **Only admit specific domains** | Connection tab | leave it off to admit every domain you have proven for single sign-on; tick it to narrow to a list | | **Create accounts automatically on first sign-in** | Connection tab | on, unless you invite everyone by hand | | **Default role** | Attributes & groups tab | `member` or `readonly` | | **IdP signing certificate** | Certificates tab → *Add a certificate* | the downloaded signing certificate, with or without the BEGIN/END lines | The **Issuer ID** is the environment's, not the application's — every SAML application in one PingOne environment shares it. That is correct and KnoxCall stores it as given: an entity ID is a **name**, and the sign-on endpoint is what distinguishes one application from another. The two fields that *are* dereferenced, the sign-in URL and the single-logout URL, must both be https and are refused otherwise. Saving needs a re-authentication in the last five minutes; that is expected, and it is the same bar as changing a password. ## 5. Grant access, then test Open the application's **Access** tab and add the groups that should have KnoxCall, then switch the application **on** with the toggle at the top of the application list. PingOne refuses sign-in to anyone outside those groups before KnoxCall ever sees a request, and an application left off refuses everyone. Test with one account: open `https://acme.knoxcall.com/saml/acme/login`, sign in, and check the person lands in the workspace with the role you expected. If it fails, the login page names the cause — every message and code is in [troubleshooting](/sso/troubleshooting). ## Single Logout Both directions are already configured if you followed the steps above: KnoxCall's SLO endpoint went into PingOne in step 1, and PingOne's went into the KnoxCall connection in step 4. Leave the KnoxCall field empty if you do not want the SP-initiated direction. Empty means "this connection does not do SP-initiated Single Logout" and it never falls back to the sign-in URL. Either way, signing out of KnoxCall always ends the KnoxCall session — never conditionally on the IdP leg. The full behaviour is in [Single Logout](/sso/overview#single-logout). ## Rotating the PingOne signing certificate A PingOne signing certificate is a **key pair in the environment**, created under **Certificates & Key Pairs** and selected by the application. So a rotation is: create the new key pair, teach KnoxCall about it, then point the application at it. Rotate **add-first**: 1. In PingOne, **Certificates & Key Pairs → Key Pairs → + → Generate**, or rotate the existing pair. Do not change the application yet. 2. Copy the new certificate into the KnoxCall connection. Pasting a certificate **adds** it — a connection can hold several active certificates and an assertion signed by any of them verifies. 3. On the application's **Configuration** tab, select the new key pair. 4. Retire the old certificate in KnoxCall. Doing it in that order means there is no instant at which the certificate PingOne is signing with is not one KnoxCall trusts. Retiring the only active certificate is refused outright: *"This is the connection's only active IdP certificate. Add the new certificate first, then retire this one."* Because the key pair is the environment's, step 3 is per application: rotating here does not move your other PingOne applications, and switching each of them over is a separate decision. ## PingFederate PingFederate is a federation server you operate, so **every value is yours and none of them is on this page.** The shape of the work is the same and the two halves are: 1. **Create an SP connection** for KnoxCall — browser SSO, SAML 2.0, SP-initiated — and give it KnoxCall's metadata. PingFederate imports an SP metadata document directly, and `https://acme.knoxcall.com/saml/acme/metadata` is one: it carries the entity ID, the ACS, the SLO endpoint and both certificates. Sign the assertion, use RSA-SHA256, and map the NameID to an attribute that does not change when a person is renamed. 2. **Give KnoxCall your IdP metadata.** PingFederate publishes it at `/pf/federation_metadata.ping?PartnerSpId=` on your own host, and you can export it from the console. Import it the same way as above, with `{ "url": … }` or `{ "xml": … }`, previewing first. Two PingFederate-specific things to check, because they are what KnoxCall sees go wrong: * **Your metadata URL has to be reachable from the public internet for the URL form to work.** A PingFederate behind a VPN is not, and the import will fail rather than hang — paste the document with `{ "xml": … }` instead. * **PingFederate's default NameID is often the `SAML_SUBJECT` from your adapter**, which in an Active Directory deployment is frequently the UPN or the email. Both change on a rename. Map it to `objectGUID`, an employee number, or another value that does not. Everything else on this page — the attributes KnoxCall reads, the group map's bound, the add-first certificate rotation, the five-minute re-authentication on save — is the same. # SSO Troubleshooting Source: https://docs.knoxcall.com/sso/troubleshooting Every SAML error code KnoxCall can put on the login page, every refusal the SSO settings form can return, and what to do about each. # SSO Troubleshooting Two surfaces produce SSO errors, and they behave differently on purpose. * **The sign-in path** — `/saml//login`, `/saml//acs`, `/saml//slo` — is **public and unauthenticated**. Anyone who knows a workspace slug can reach it. So its answers are deliberately uninformative: the person is sent to the login page with a short `error` code, and the reason lives in your workspace's audit and security events, where only an administrator can read it. A public endpoint that explained itself would explain itself to anybody. * **The settings form** — everything under `/admin/saml` — is behind the owner-or-admin role and a recent re-authentication, so it says exactly what is wrong with the value you typed. Those messages are quoted verbatim below. **Start here for almost everything.** Most first-login failures are one of three things: the audience does not match (KnoxCall's SP entity ID *is* its metadata URL, `https://acme.knoxcall.com/saml/acme/metadata`, not the origin), the assertion is older than five minutes, or the asserted address is at a domain the workspace has not [proven](/sso/domains). Those `/saml//…` URLs are the `default` connection's — the `/saml/` alias **is** that connection. Any other connection has its own set at `https://acme.knoxcall.com/sso///{metadata,acs,login,slo}`, so check the one you are debugging on its **Certificates** tab before comparing anything. Fetch your own metadata document and compare it against what your IdP has, before anything else. ## Error codes on the login path The code arrives as `?error=` on the login page, or as a JSON `{ "error": "" }` from the metadata and logout endpoints. | Code | Where it comes from | What it means, and what to do | | - | - | - | | `saml_not_configured` | any `/saml//…` route, 404 | No **active** SAML connection for that workspace slug. Either the slug is wrong, SSO was never enabled, or the connection is in `draft`, `testing` or `disabled`. Set it to active on the SSO settings page. The same 404 answers a workspace under an operator hold — it does not distinguish, and that is deliberate. | | `saml_connection_ambiguous` | any `/saml//…` route, 409 | The alias resolves to the `default` connection; this workspace has two or more active connections and **none** of them carries that handle, so the shared URL cannot tell which identity provider an assertion is for. What the caller is told, in full: *"More than one SSO connection is active for this workspace and none of them is the one this shared sign-in URL belongs to, so it cannot tell which one you meant. Your administrator has a connection-specific URL for each of them; ask them for the one for your identity provider."* — and the logout endpoint's twin is the same sentence with *"…this shared logout URL belongs to…"*. Nothing needs disabling. Point each identity provider at its own connection's `/sso///{metadata,acs,login,slo}` URLs — they name a connection and cannot be ambiguous — or keep the identity provider that was there first on the connection named `default`, which the alias belongs to. The response names no connection: an unauthenticated caller must not learn the shape of your identity estate. The SSO settings page names them, and each connection's **Certificates** tab carries its five URLs. | | `sso_plan_required` | `/login` (403) and the ACS | The workspace has no Enterprise entitlement **and no existing connection to grandfather**. A *lapse* on a workspace that already had Enterprise never produces this: existing logins keep working through a billing lapse by design. | | `saml_metadata_unavailable` | `/saml//metadata` | The metadata document could not be generated. Retry; if it persists, the connection is missing something the document needs — check it has an IdP entry point. Note the endpoint is **not** plan-gated: IdP setup leaks nothing, so metadata stays fetchable. | | `saml_login_failed` | `/saml//login` | KnoxCall could not build the AuthnRequest — nearly always a malformed IdP sign-in URL on the connection. Re-check it against your IdP. | | `saml_idp_initiated_disabled` | the ACS | An **unsolicited** assertion arrived (an app-launcher tile) and this connection has not opted in. Either point the tile at `https://acme.knoxcall.com/saml/acme/login` so it becomes an ordinary SP-initiated sign-in, or switch the setting on — see [IdP-initiated sign-in](/sso/overview#idp-initiated-sign-in). | | `saml_replay` | the ACS | This assertion's ID has already been used. Each is accepted exactly once. A person double-clicking a stale browser tab produces this; so does a replayed capture. Sign in again. | | `saml_failed` | the ACS | The catch-all: signature, audience, timing, or the account rules below. The person is given nothing more; the reason is in your workspace's **security events** as `auth.login_failed` with the detail `saml acs: assertion rejected`. The next section lists every underlying cause. | | `saml_logout_message_missing` | the SLO endpoint | A request reached `/saml//slo` carrying neither a `SAMLRequest` nor a `SAMLResponse`. Usually a health check or a bookmark, not a fault. | | `saml_logout_unsigned` | the SLO endpoint | A redirect-binding logout message arrived without a signature. KnoxCall refuses it — otherwise anyone who could reach the URL could sign out any user whose email address they could guess. Enable message signing on your IdP's logout endpoint. | | `saml_logout_invalid` | the SLO endpoint | The logout message did not verify, or was not the shape it claimed. Usually the signing certificate on the connection is not the one the IdP is signing logout messages with — the same rotation applies to logout as to sign-in. | | `saml_logout_stale` | the SLO endpoint | The message's `IssueInstant` is more than **five minutes** old — the same ceiling as an assertion. A queued or retried logout can arrive late; the session is ended by the next sign-out either way. | | `saml_logout_replay` | the SLO endpoint | That logout message has already been processed. Accepted once, exactly like an assertion. | | `domain_not_verified` | `POST /admin/saml/domains//verify` (400) | The DNS proof did not check out. The response carries the reason as `error_description` — the four possibilities are in [Verifying a domain](/sso/domains#when-verification-fails). | ## What is really behind `saml_failed` The assertion verified, and then an account rule refused it. The person sees `saml_failed`; you see one of these in the workspace's events. All six are quoted verbatim. | Message | What happened | What to do | | - | - | - | | *"This email already has a KnoxCall account that is not a member of this tenant. Ask an administrator to invite you, or have them add your email domain to the tenant's verified SAML domains."* | The address already has a KnoxCall account somewhere, and it is not a member here. Linking it on the strength of an assertion alone would be account takeover — your IdP signs its own assertions, so it could claim any address. | Either invite the person to the workspace directly, or [prove the domain](/sso/domains) and list it on the connection. | | *"This identity is known to this tenant but is not currently a member, and the KnoxCall account it is bound to is not within the tenant's verified SAML domains. Ask an administrator to invite you."* | The IdP's NameID is already bound to a KnoxCall account from an earlier sign-in, that account is no longer a member (an administrator removed it), and the account's **own** address is outside your verified domains. The address in the assertion does not decide this: the membership would land on the bound account, so that account is what your domains must cover. This is typically an invited outside collaborator who was removed. | Invite them again from the team page if they should have access. Changing the address your IdP sends will not help, by design. | | *"No KnoxCall account exists for this email and JIT provisioning is disabled for this tenant."* | New address, and **Create accounts automatically** is off. | Invite them, or turn just-in-time provisioning on. | | *"Just-in-time SAML provisioning requires this tenant to have verified email domains configured. Add them under SSO settings, or invite users directly…"* | Just-in-time provisioning is on, but the connection lists no verified domain. An empty list means invite-only, never allow-everything — otherwise a workspace could seed accounts for addresses it does not control. | [Prove a domain](/sso/domains) and list it. | | *"This email domain is not permitted for SAML sign-in to this tenant."* | The asserted address is at a domain outside the proven, listed set. Note that proving `acme.com` covers `eu.acme.com`; proving a subdomain never covers its parent. | Prove and list the domain the address is actually at. | | *"This KnoxCall account is deactivated. Ask an administrator to reactivate it before signing in."* | The account exists and is deactivated. A signed assertion does not re-admit a deactivated account — that would make deactivation a suggestion. | Reactivate them on the team page, or leave it: this is deactivation working. | There is a seventh, for a misconfigured connection rather than a person: if the connection's default role is anything an IdP-asserted login may not confer, **every** login fails cleanly with a message naming the role and telling you to lower it. That state is unreachable through the settings form, which refuses the value at save time. ## Refusals from the SSO settings form These come back as a `400` with the message shown to you directly. Fix the value and save again. ### The IdP sign-in URL and the single-logout URL Both are **dereferenced** — a browser is sent to them — so both are held to the same rule. | Message | What to do | | - | - | | *"The IdP sign-in URL is required."* | Every connection needs one. It is the URL your IdP's SAML setup screen calls the SSO URL, login URL or Identity Provider Single Sign-On URL. | | *"…must be a full URL, for example [https://login.example.com/sso/saml](https://login.example.com/sso/saml)."* | You pasted a bare host or a path. Include the scheme. | | The **sign-in URL** must use https, *"The AuthnRequest carries the login request ID that binds the assertion to this login, and over http it can be read and rewritten in transit."* | `http` is not a lesser version of https here. | | The **single-logout URL** must too, *"A LogoutRequest carries the NameID — usually the person's email address — and the SessionIndex, and the browser is redirected to this URL, so over http both are readable by anything on the path."* | Over `http` the binding is decoration and the identifiers are public. | | *"…must not contain a username or password. A URL like [https://login.example.com@somewhere-else.example/](https://login.example.com@somewhere-else.example/) points at somewhere-else.example, not at your IdP."* | Almost always a mis-paste. No IdP endpoint has userinfo in it. | | *"…contains control characters."* | A tab or newline came along with the paste. Retype it. | The IdP **entity ID** is not held to any of this: it is a name, not an address, nothing dereferences it, and Okta's genuinely begins `http://www.okta.com/`. It is checked only for length and control characters. ### The signing certificate | Message | What to do | | - | - | | *"That does not look like an X.509 certificate. Paste the IdP signing certificate, including the BEGIN/END lines if your IdP shows them."* | What you pasted is not base64 and is not PEM. Download the certificate from the IdP rather than copying it off a screen. | | *"That is a private key, not a certificate. Paste the IdP's PUBLIC signing certificate."* | A `BEGIN PRIVATE KEY` block. KnoxCall only ever wants your IdP's **public** certificate — it never needs a private key of yours, and this field is rendered back on the settings page. | | *"That certificate could not be parsed as X.509. Copy the IdP signing certificate exactly as your identity provider shows it."* | It is base64, but not a decodable certificate — usually a truncated copy. | A bare base64 body is fine; so is full PEM. KnoxCall normalises what you paste and stores the PEM form. **Expiry is deliberately not refused** — refusing to save a config whose certificate expires next week would block the very edit that fixes it. ### Roles, domains and the rest | Message | What to do | | - | - | | *"Default role must be one of `member, readonly`. Just-in-time provisioning may never create an owner or an admin — elevate members on the team page."* | The bound is absolute. Owner and admin are granted by a human, on the team page, and by nothing else. | | *"Group `'Engineering'` maps to `'owner'`; an IdP group may only map to `member or readonly` — an IdP-asserted login may never mint an administrator (elevate members on the team page)."* | Same bound, applied to the group map. | | *"At least one verified email domain is required. Without it, SAML sign-in cannot create or link any account for this tenant — just-in-time provisioning is refused outright, and a pre-existing account that is not already a member is refused too."* | The list is empty. [Prove a domain](/sso/domains) and list it. | | *"…is not covered by a domain you have verified for single sign-on…"* | The list names a domain whose SSO proof does not cover it. Prove that domain, or list one that is covered. A domain proven for **sending mail** is a different proof and does not count here. | | *"Allow IdP-initiated sign-in must be true or false."* | You sent the string `"true"` rather than the boolean. `"false"`, `"0"` and `"no"` are all truthy in JavaScript, and this is the one field whose default is *deny* — so the shape is refused rather than guessed at. | | *"An SSO connection cannot be activated without at least one active IdP certificate."* | You set the status to active before adding a certificate. Add one first. | | *"An SSO connection cannot be activated without an IdP entry point."* | Same, for the sign-in URL. | | *"This is the connection's only active IdP certificate. Add the new certificate first, then retire this one."* | Rotation is **add-first**: add the new certificate, switch the IdP over, then retire the old one. | | *"Single sign-on configuration requires a current Enterprise plan. Existing SSO connections keep authenticating your members; adding or changing one needs the subscription brought up to date."* | Bring the subscription up to date. Nobody is locked out in the meantime — this refuses **configuration**, never login. | | *"Managing the SAML connection requires the owner or admin role — this row is the tenant's authentication policy…"* | You are signed in as a member or a read-only user. Ask an owner or an admin. KnoxCall staff cannot write it either — the same surface refuses them. | Saving also needs a re-authentication in the **last five minutes**. A `403` asking you to re-authenticate is that, not a permissions problem — it is the same bar as changing a password, because this row names the identity provider allowed to assert who your users are. ## Refusals from the IdP metadata import `POST /admin/saml/connections//metadata` reads your identity provider's federation metadata; the flow is [importing your IdP's metadata](/sso/overview#importing-your-idps-metadata). Every refusal below is decided **before anything is written**, and each carries a machine-readable code alongside the message. | Message | Code | | - | - | | *"Send either — the document itself — or , where to fetch it from — but not both."* | `metadata_source_ambiguous` | | *"That document carries a DOCTYPE or entity declaration. KnoxCall will not process one: an XML entity can read files off our servers or expand into gigabytes of text, and no identity provider needs one to publish its metadata. Export the metadata again — Entra, Okta, Google and JumpCloud all produce a document without a DOCTYPE."* | `metadata_doctype_forbidden` | | *"That document is `312` KiB; the limit is `256` KiB. Federation metadata for one application is a few kilobytes — a document this size is usually a whole federation."* | `metadata_too_large` | | *"That document is an EntitiesDescriptor — a federation describing several identity providers — and KnoxCall will not guess which one you meant. Export the metadata for the single application you are connecting, or paste just its `` element."* | `metadata_not_entity_descriptor` | | *"That document describes an entity but not an identity provider — it carries no ``…"* — you exported the SP half, which is the document KnoxCall gives *you* to upload to your IdP. | `metadata_no_idp_descriptor` | | *"That document publishes no HTTP-Redirect or HTTP-POST `` endpoint, so there is nowhere to send a sign-in request."* | `metadata_no_sso_endpoint` | | *"That document carries no signing certificate. Without one KnoxCall cannot tell a real assertion from a forged one, so the connection cannot be activated from it."* | `metadata_no_signing_certificate` | | The metadata URL must use **https**, and an `http:` one is refused before anything is fetched: *"KnoxCall fetches this document to read your IdP's signing certificate; over http that fetch — and the certificate it returns — can be rewritten in transit."* | `metadata_url_rejected` | | *"That URL answered `302` — a redirect. KnoxCall does not follow redirects when fetching metadata, because the address we checked would not be the address we read. Use the URL the redirect points at, if it is one you recognise."* | `metadata_fetch_redirect` | | *"KnoxCall could not fetch that metadata URL. It must be reachable from the public internet over https, and it may not resolve to a private, loopback or link-local address."* | `metadata_fetch_failed` | | *"Every signing certificate in that document has expired, and this connection has no unexpired certificate of its own — importing it would leave every sign-in failing signature validation. Re-export the metadata once your identity provider has issued its new certificate."* | `metadata_all_certificates_expired` | An import **never clears** what the document does not carry. A metadata export made before you switched Single Logout on has no `SingleLogoutService`, and KnoxCall leaves your stored SLO URL exactly where it was rather than reading the absence as "turn it off". It never retires a certificate either — rotation stays add-first — and it does not touch the domain list. ## Symptoms with no error code **Everything worked, then every login started failing at once.** The IdP rotated its signing certificate. Add the new one to the connection — [rotation, with overlap](/sso/overview#certificate-rotation-with-overlap). **Logins fail only for people who have never signed in before.** The account rules, not the assertion: check the connection's verified email domains and whether just-in-time provisioning is on. **Everyone lands with the wrong role.** Group mapping decides the role a person gets when they are **first** added. Moving someone between groups in your IdP later does not change their KnoxCall role — change it on the team page. **Sign-in works from KnoxCall but not from the IdP's app tile.** That tile is an IdP-initiated login. Point it at `https://acme.knoxcall.com/saml/acme/login` instead, which is the better answer anyway. **Repeated attempts start failing.** The ACS and the SLO endpoint each accept **30 requests per minute** per workspace and source address, with separate budgets so a logout storm cannot spend your sign-in allowance. Wait a minute. **Assertion encryption is not offered by the IdP at all.** The app was configured from SP metadata that predates our encryption key. Re-upload the metadata — this is the [8 September 2026 call-out](/sso/overview) on every guide. ## Still stuck Collect these before contacting support — they are what makes a report actionable, and none of them is a secret: * your workspace slug and the URL you signed in from; * the `error=` code on the login page, and roughly when; * your IdP's SAML tracer output, if you can capture one (Entra, Okta and JumpCloud all have one; browser extensions work everywhere); * the SP metadata document your IdP currently holds, so it can be diffed against `https://acme.knoxcall.com/saml/acme/metadata`. Do not send a private key. KnoxCall never needs one from you. # In-App Support Chat Source: https://docs.knoxcall.com/support/support-chat Get instant help with the KnoxCall support chat widget. Chat with our team directly from the admin UI for quick assistance. # In-App Support Chat Get instant help with KnoxCall's integrated support chat powered by Chatwoot. ## Overview The **Support Chat** widget provides real-time assistance: * 💬 Live chat with KnoxCall support team * ⚡ Instant responses during business hours * 📎 Send screenshots and files * 📧 Email notifications for offline messages * 💾 Conversation history preserved * 🔒 Secure and private **Available on:** All plans (Free, Starter, Pro, Enterprise) ## Accessing Support Chat ### Chat Widget Location **Look for the chat bubble:** * Bottom-right corner of admin UI * Blue/purple circular icon * Shows unread message count (if any) **Click to open:** 1. Click chat bubble 2. Chat window slides up 3. Start conversation ### When Chat is Available **Business Hours** (EST): * Monday-Friday: 9am-6pm * Saturday-Sunday: Closed **Status indicators:** * 🟢 **Online**: Team available now * 🟡 **Away**: Team may be unavailable, response delayed * 🔴 **Offline**: Outside business hours **Offline messages:** * Still send messages when offline * Team responds when back online * Email notification sent when responded ## Starting a Conversation ### First Time 1. Click chat bubble 2. Enter your name (pre-filled from account) 3. Enter email (pre-filled from account) 4. Type your message 5. Click **Send** **Conversation started!** ### Returning User **Conversation history preserved:** * Previous messages visible * No need to re-introduce yourself * Continue where you left off ## What You Can Ask ### Technical Support **Great for:** * ❓ Configuration questions * 🐛 Reporting bugs * ⚙️ Feature assistance * 🔧 Troubleshooting errors * 📚 Documentation clarification **Examples:** ```text theme={"dark"} "How do I inject a secret into the request body?" "My route is returning 502 errors, can you help?" "What's the difference between environments and method configs?" ``` ### Account & Billing **Great for:** * 💳 Billing questions * 📊 Plan comparisons * 🔄 Upgrade/downgrade assistance * 🧾 Invoice requests * 💰 Refund inquiries **Examples:** ```text theme={"dark"} "I want to upgrade to Pro, how do I do that?" "Can you send me an invoice for last month?" "What's included in the Enterprise plan?" ``` ### Feature Requests **Great for:** * 💡 Suggesting new features * 🗳️ Voting on roadmap items * 🎨 UX improvements * 🔌 Integration requests **Examples:** ```text theme={"dark"} "Would be great to have Datadog integration!" "Can you add support for GraphQL endpoints?" "The visual body editor could use dark mode" ``` ### Not Ideal For **Better alternatives:** **Emergency Incidents:** * Use alert email/SMS notifications * Enterprise: Dedicated support channel **Complex Debugging:** * Export logs → Send via email * Schedule screen-share call **Sales Inquiries:** * Email: [sales@knoxcall.com](mailto:sales@knoxcall.com) * Enterprise demos and quotes ## Chat Features ### Send Screenshots **Attach images:** 1. Click paperclip icon 2. Select screenshot 3. Image uploads and sends **Great for:** * Showing error messages * UX issues * Configuration screenshots **Tip:** Use browser screenshot tools or OS snipping tool. ### Send Files **Attach files:** * Logs (CSV exports) * Configuration files (JSON) * Error reports **Max file size:** 10MB ### Rich Formatting **Text formatting:** ```text theme={"dark"} **Bold text** *Italic text* `Code snippets` ``` **Links:** ```text theme={"dark"} https://docs.knoxcall.com ``` **Code blocks:** ```json theme={"dark"} { "route": "test", "error": "timeout" } ``` ### Emoji Support **Add context with emoji:** ```text theme={"dark"} 🚨 Urgent issue! ✅ Problem solved, thank you! 🤔 Not sure I understand... ``` ## Response Times We reply during business hours, as quickly as we can. No response time is guaranteed on the Free, Starter or Pro plans. Enterprise support terms are set in your Enterprise agreement. ## Email Notifications **Get notified when:** * ✉️ Support team responds * 📬 New message while you're away * 🔔 Chat was offline, now online **Email includes:** * Message preview * Link to continue conversation * Option to respond via email ## Conversation History **Your message history:** * All past conversations saved * Search previous conversations * Review solutions to past issues **Access history:** 1. Open chat widget 2. Click menu icon (top-right) 3. Select "Conversation History" 4. Browse past chats **Privacy:** * Only you see your conversations * Not shared with other users * Team members can see (for context) ## Tips for Best Support ### 1. Be Specific ✅ **Good:** ```text theme={"dark"} "I created a route called 'stripe-payments' that targets api.stripe.com. When I send a POST request, I get a 502 error. The route is enabled and I'm using the correct API key." ``` ❌ **Vague:** ```text theme={"dark"} "My route isn't working." ``` ### 2. Include Context **Helpful information:** * Route name * Environment (production, staging, dev) * Error messages (exact text) * What you expected vs. what happened * Steps to reproduce ### 3. Attach Screenshots **When useful:** * Error messages * Configuration screens * Unexpected UI behavior ### 4. Share Relevant IDs **Include:** * Route ID (e.g., `route_abc123`) * Request ID (from logs) * API key ID (not full key!) * Error timestamps **Don't share:** * Full API keys * Secret values * Sensitive credentials ### 5. Check Documentation First **Before chatting:** 1. Search docs.knoxcall.com 2. Check troubleshooting sections 3. Review related guides **If still stuck:** Chat us! We're here to help. ## Privacy & Security **Your data:** * 🔒 Conversations encrypted in transit (TLS) * 🔐 Stored securely * 👁️ Only visible to you and support team * 🗑️ Deleted on request **What we don't collect:** * Secret values * Full API keys * Sensitive customer data **Best practices:** * Don't paste full API keys * Redact sensitive info from screenshots * Use key IDs instead of full keys ## Troubleshooting ### Issue: "Chat widget not appearing" **Causes:** * Ad blocker blocking widget * Browser extension conflict * JavaScript disabled **Fix:** 1. Disable ad blocker on knoxcall.com 2. Try different browser 3. Ensure JavaScript enabled ### Issue: "Can't send messages" **Causes:** * Network connectivity issue * Firewall blocking chatwoot domain * Browser cache issue **Fix:** 1. Check internet connection 2. Clear browser cache 3. Refresh page 4. Try incognito mode ### Issue: "No response from support" **Possible reasons:** * Outside business hours (check status indicator) * High ticket volume (wait a bit longer) * Message went to spam (check email) **If urgent:** * Email [support@knoxcall.com](mailto:support@knoxcall.com) * Mention chat conversation ID ## Alternative Support Channels **If chat unavailable:** ### Email Support * [support@knoxcall.com](mailto:support@knoxcall.com) * Response within 24 hours * Good for detailed issues ### Community Forum * community.knoxcall.com * Ask other users * Share solutions * Vote on feature requests ### Documentation * docs.knoxcall.com * Comprehensive guides * Troubleshooting sections * Code examples ### GitHub Issues * github.com/knoxcall/issues * Bug reports * Feature requests * Public roadmap ## Enterprise Support **Enterprise plan includes:** * 🎯 **Dedicated Slack channel** - Direct access to engineering team * 📞 **Phone support** - Call for critical issues * 📺 **Screen-share sessions** - Live troubleshooting * 🚀 **Priority bug fixes** - Issues resolved faster * 👔 **Account manager** - Dedicated point of contact **Contact sales:** [sales@knoxcall.com](mailto:sales@knoxcall.com) to upgrade ## Feedback on Support **Rate your experience:** * After conversation resolved, rate support (1-5 stars) * Leave feedback comment * Helps us improve! **We read every piece of feedback** and continuously improve our support. ## Related Resources * **Documentation**: Comprehensive guides at docs.knoxcall.com * **Status Page**: Service status at status.knoxcall.com * **Blog**: Tips and updates at blog.knoxcall.com ## Next Steps Get help now Browse guides Send detailed inquiry Ask the community *** * **Level**: beginner * **Time**: 2 minutes `support`, `chat`, `help`, `assistance`, `troubleshooting` # Common Errors Reference Source: https://docs.knoxcall.com/troubleshooting/common-errors Quick reference for KnoxCall error codes and how to fix them. Troubleshoot 403, 404, 502, 504 errors and more. # Common Errors Reference Quick solutions to the most common KnoxCall errors. *** ## 403 Forbidden ### Error Message ```json theme={"dark"} { "error": "Request could not be identified as an authorized client for the configured environment" } ``` ### What It Means Your IP address isn't whitelisted for this route. ### Quick Fix 1. Check your IP: `curl https://api.ipify.org` 2. Go to **Resources** → **Clients** 3. Update or create client with your IP 4. Assign client to route ### See Also [IP Authorization Issues Guide](/troubleshooting/ip-authorization-issues) - Comprehensive troubleshooting *** ## 404 Route Not Found ### Error Message ```json theme={"dark"} { "error": "Route not found" } ``` ### What It Means The route name in your `x-knoxcall-route` header doesn't exist. ### Common Causes #### 1. Typo in Route Name ```bash theme={"dark"} # ❌ Wrong -H "x-knoxcall-route: user-ap1" # Typo: "ap1" instead of "api" # ✅ Correct -H "x-knoxcall-route: user-api" ``` **Solution:** Double-check spelling. Route names are **case-sensitive**. #### 2. Route Disabled **Check:** 1. Go to **Routes** 2. Find your route 3. Is **Enabled** toggle ON? **Solution:** Enable the route. #### 3. Route Deleted **Symptom:** Route worked before, now 404. **Solution:** Someone deleted it. Recreate the route or check with your team. #### 4. Wrong Environment ```bash theme={"dark"} # Route exists in "production" but you're calling "staging" -H "x-knoxcall-environment: staging" ``` **Solution:** * Use correct environment name * Or create route in the environment you're calling *** ## 401 Unauthorized ### Error Message ```json theme={"dark"} { "error": "Invalid API key", "status": 401 } ``` ### What It Means Your KnoxCall API key (`x-knoxcall-key`) is invalid or missing. This error is specific to the legacy `x-knoxcall-key` API-key header. For new integrations we recommend short-lived OAuth 2.1 access tokens instead — send `Authorization: Bearer kc_…` together with `x-knoxcall-route`. See [Authentication](/api-reference/authentication). ### Common Causes #### 1. Missing API Key ```bash theme={"dark"} # ❌ Missing x-knoxcall-key header curl https://a1b2c3d4.acme.knoxcall.com/api/users \ -H "x-knoxcall-route: user-api" ``` **Solution:** Add the API key header: ```bash theme={"dark"} curl https://a1b2c3d4.acme.knoxcall.com/api/users \ -H "x-knoxcall-key: tk_abc123..." \ -H "x-knoxcall-route: user-api" ``` #### 2. Wrong API Key **Check:** * Did you copy the full 77-character key? * Is it the correct key for this tenant? * Has the key been deleted? **Solution:** 1. Go to **Settings** → **Tenant** tab → **API Keys** 2. Verify key exists and is enabled 3. Generate new key if needed #### 3. API Key Deleted **Symptom:** Worked before, now 401. **Solution:** Someone deleted the key. Generate a new one. *** ## 502 Bad Gateway ### Error Message ```json theme={"dark"} { "error": "Bad Gateway", "status": 502, "message": "Error connecting to upstream server" } ``` ### What It Means KnoxCall couldn't connect to your backend API. ### Common Causes #### 1. Backend Server Down **Your backend API isn't running or crashed.** **Test directly:** ```bash theme={"dark"} curl https://your-backend-api.com/api/users ``` **If this fails:** Your backend is down. Restart it. #### 2. Wrong Target URL **Route points to incorrect backend URL.** **Check:** 1. Go to **Routes** → Your route 2. Look at **Target Base URL** 3. Is it correct? **Common mistakes:** ```text theme={"dark"} ❌ https://api.example.com/api (extra /api) ✅ https://api.example.com ❌ http://localhost:3000 (localhost not accessible) ✅ https://your-server.com ``` #### 3. Firewall Blocking **Your backend firewall is blocking KnoxCall's requests.** **Solution:** * Whitelist KnoxCall's IP addresses in your backend firewall * Or disable firewall for testing (not recommended for production) #### 4. SSL/TLS Issues **Backend has invalid SSL certificate.** **Symptoms:** ```json theme={"dark"} { "error": "SSL certificate problem: self signed certificate" } ``` **Solution:** * Use valid SSL certificate on backend * Don't use self-signed certs in production #### 5. DNS Not Resolving **Backend domain doesn't exist or isn't resolving.** **Test:** ```bash theme={"dark"} curl https://your-backend.com ``` **If "Could not resolve host":** DNS issue. Check domain registration and DNS records. *** ## 504 Gateway Timeout ### Error Message ```json theme={"dark"} { "error": "Gateway Timeout", "status": 504, "message": "Upstream server did not respond in time" } ``` ### What It Means Your backend API took too long to respond (>30 seconds default). ### Common Causes #### 1. Slow Backend Processing **Your API is taking forever to process the request.** **Examples:** * Heavy database query * Third-party API call timing out * Large file processing * Infinite loop in code **Solution:** * Optimize your backend code * Add database indexes * Use async processing for long tasks * Implement timeouts on third-party calls #### 2. Backend Deadlock **Your backend is stuck waiting for something.** **Check:** * Database connections maxed out? * Waiting for external service? * Thread pool exhausted? **Solution:** Restart backend server, investigate logs. #### 3. Network Latency **Slow network between KnoxCall and your backend.** **Test:** ```bash theme={"dark"} curl -w "Time: %{time_total}s\n" https://your-backend.com/api/test ``` **If >30 seconds:** Network or backend is too slow. ### See Also [504 Errors Deep Dive](/troubleshooting/troubleshooting-504-errors) *** ## 500 Internal Server Error ### Error Message ```json theme={"dark"} { "error": "Internal Server Error", "status": 500 } ``` ### What It Means Either KnoxCall or your backend had an unexpected error. ### How to Debug #### Check Which Side Failed **If error details show:** ```json theme={"dark"} { "error": "Internal Server Error", "upstream_status": 500 } ``` → Your backend returned 500 (backend problem) **If error details show:** ```json theme={"dark"} { "error": "Internal Server Error", "upstream_status": null } ``` → KnoxCall internal error (rare) #### Backend 500 Errors **Solution:** 1. Check your backend logs 2. Look for stack traces, exceptions 3. Fix the bug in your backend code 4. Common causes: * Null pointer exceptions * Missing environment variables * Database connection errors * Uncaught exceptions #### KnoxCall 500 Errors **Solution:** 1. Check **Logs** → **API Logs** for details 2. Contact support if it's a KnoxCall bug 3. Check if it's a secret resolution issue *** ## 429 Too Many Requests ### Error Message ```json theme={"dark"} { "error": "Too Many Requests", "status": 429, "retry_after": 60 } ``` ### What It Means You've hit a rate limit. ### Common Causes #### 1. Route Rate Limit **Your route has rate limiting enabled.** **Check:** 1. Go to **Routes** → Your route 2. Check **Rate Limiting** settings 3. What's the limit? (e.g., 100 requests/minute) **Solution:** * Slow down your requests * Increase rate limit (if you control it) * Use exponential backoff and retry #### 2. API Key Rate Limit **Your API key has a global rate limit.** **Solution:** Wait for the rate limit window to reset (check `retry_after` in response). #### 3. Burst Traffic **You sent a burst of requests all at once.** Your plan's account-wide limit admits at most **ten seconds** of its per-minute rate at once (17 / 167 / 834 / 1,667 requests on Free / Starter / Pro / Enterprise). A 429 with `X-RateLimit-Layer: tenant` while you are under your per-minute rate means the burst ran out: the response's `X-RateLimit-Burst` header (and `burst` in the body) is that bucket, and `Retry-After` is at most about ten seconds. **Solution:** * Implement request queuing * Add delays between requests * Use connection pooling *** ## Secret-Related Errors ### Secret Not Found **Error:** ```json theme={"dark"} { "error": "Secret 'stripe_key' not found", "status": 500 } ``` **What It Means:** Route references a secret that doesn't exist. **Solution:** 1. Go to **Resources** → **Secrets** 2. Check if `stripe_key` exists 3. If not, create it 4. If yes, check spelling in route config ### Template Syntax Error **Error:** ```json theme={"dark"} { "error": "Invalid template syntax in inject_headers_json" } ``` **What It Means:** Wrong template format in route config. **Common mistakes:** ```text theme={"dark"} ❌ {{stripe_key}} (missing "secret:" prefix) ❌ {secret:stripe_key} (single braces) ❌ {{ secret:stripe_key }} (spaces inside braces) ✅ {{secret:stripe_key}} (correct!) ``` ### Secret Decryption Error **Error:** ```json theme={"dark"} { "error": "Failed to decrypt secret" } ``` **What It Means:** Secret value is corrupted or encryption key changed. **Solution:** 1. Delete the secret 2. Recreate with same name 3. Update value 4. Test again *** ## Environment Errors ### Environment Not Found **Error:** ```json theme={"dark"} { "error": "Environment 'staging' not found", "status": 404 } ``` **What It Means:** You specified an environment that doesn't exist. **Solution:** 1. Go to **Resources** → **Environments** 2. Check if "staging" exists 3. Create it if missing 4. Or use existing environment name ### No Override Configuration **Error:** ```json theme={"dark"} { "error": "No configuration found for environment 'staging'" } ``` **What It Means:** Environment exists but has no config for this route. **Solution:** 1. Go to **Routes** → Your route 2. Click **Environment** tab 3. Add configuration for "staging" environment *** ## Network/Connection Errors ### ECONNREFUSED **Error:** ```json theme={"dark"} { "error": "connect ECONNREFUSED 127.0.0.1:3000" } ``` **What It Means:** Backend server is not running or not listening on that port. **Solution:** * Start your backend server * Check port is correct * Don't use localhost/127.0.0.1 (not accessible from KnoxCall) ### ENOTFOUND **Error:** ```json theme={"dark"} { "error": "getaddrinfo ENOTFOUND api.example.com" } ``` **What It Means:** DNS lookup failed. Domain doesn't exist. **Solution:** * Check domain spelling * Verify domain is registered * Check DNS records are configured ### ETIMEDOUT **Error:** ```json theme={"dark"} { "error": "connect ETIMEDOUT" } ``` **What It Means:** Connection attempt timed out (before request even sent). **Solution:** * Check backend is accessible from internet * Verify firewall allows incoming connections * Check backend server isn't crashed *** ## Client/Authorization Errors (Summary) | Error | Meaning | Fix | | - | - | - | | `IP address not authorized` | IP not whitelisted | [See IP Authorization Guide](/troubleshooting/ip-authorization-issues) | | `Client authorization required` | Route needs client but none provided | Assign client to route | | `No clients assigned to environment` | Environment has no clients | Assign clients to environment | | `Client disabled` | Client exists but disabled | Enable client | *** ## Error Response Format All KnoxCall `/v1` API errors follow one canonical shape — a single `error` object with a machine-readable `type`, a human-readable `message`, and a bare-UUID `request_id`: ```json theme={"dark"} { "error": { "type": "forbidden", "message": "Human-readable explanation here", "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` The same `request_id` is also returned in the `X-Request-Id` response header on every response. Branch on `error.type`, not on the HTTP status or the wording of `message`. See the [Errors reference](/api-reference/errors) for the full type → status table. **Quote the `request_id` when contacting support!** *** ## Quick Debugging Steps Follow these steps for any error: ### 1. Check Error Code | Code | Category | Priority | | - | - | - | | 401 | Authentication | Check API key | | 403 | Authorization | Check IP/client | | 404 | Not Found | Check route name | | 429 | Rate Limit | Slow down requests | | 500 | Server Error | Check backend logs | | 502 | Bad Gateway | Check backend URL/status | | 504 | Timeout | Check backend performance | ### 2. Check Logs 1. Go to **Logs** → **API Logs** 2. Find your failed request 3. Click to expand details 4. Look for: * Request headers sent * Response from backend * Error stack trace ### 3. Test Backend Directly ```bash theme={"dark"} # Bypass KnoxCall, call backend directly curl https://your-backend-api.com/api/test ``` **If this fails:** Problem is with your backend, not KnoxCall. ### 4. Check Route Configuration 1. **Route enabled?** ✓ 2. **Target URL correct?** ✓ 3. **Methods allowed?** ✓ 4. **Clients assigned?** ✓ 5. **Secrets exist?** ✓ *** ## Getting Help If error persists: Step-by-step debugging guide Review detailed request logs Fix IP authorization problems Timeout troubleshooting Live help from support team **When contacting support, include:** 1. Error message (full JSON response) 2. Request ID from logs 3. What you tried 4. Route name and configuration *** ## Error Prevention Tips ### For Development * ✅ Use test API keys with "allow any IP" * ✅ Test backend directly first * ✅ Check logs after every change * ✅ Start with simple route, add complexity gradually ### For Production * ✅ Use static IPs for servers * ✅ Set up monitoring and alerts * ✅ Test in staging environment first * ✅ Have error handling in client code * ✅ Implement retry logic with exponential backoff ### General Best Practices * ✅ Keep route names descriptive and unique * ✅ Document what each secret is for * ✅ Regularly audit and clean up unused clients * ✅ Monitor API logs for patterns * ✅ Set up health check endpoints *** **Pro Tip:** 90% of errors are one of three things: IP authorization (403), route not found (404), or backend issues (502/504). Check these first! # Troubleshooting Routes Source: https://docs.knoxcall.com/troubleshooting/debugging-routes Diagnose and fix common route issues. Solutions for 404, 502, 504 errors, authentication failures, and connection problems. # Troubleshooting Routes Common issues with routes and how to fix them. Follow this guide to diagnose and resolve route problems quickly. ## Quick Diagnostics Checklist Before diving into specific errors: ```text theme={"dark"} ✓ Route is enabled (check Routes page) ✓ Using correct tenant subdomain ✓ x-knoxcall-route header matches route name exactly ✓ API key is valid (if using API key auth) ✓ Client IP is whitelisted (if using IP auth) ✓ Backend is reachable from internet ✓ Check API logs for detailed error messages ``` ## Common HTTP Status Codes ### 404 Not Found **Meaning:** Route not found or doesn't exist #### Possible Causes **1. Route name mismatch** ```bash theme={"dark"} # Wrong: -H "x-knoxcall-route: My-Route" # Correct: -H "x-knoxcall-route: my-route" ``` Route names are **case-sensitive**. **2. Route is disabled** Check Routes page - route might be toggled OFF. **Fix:** 1. Go to Routes → Find your route 2. Toggle **Enabled** to ON 3. Save **3. Wrong subdomain** ```bash theme={"dark"} # Wrong: https://wrong-subdomain.knoxcall.com # Correct: https://a1b2c3d4.yourcompany.knoxcall.com ``` Check your correct subdomain in dashboard. **4. Route deleted** If route was recently deleted, recreate it. #### Debug Steps ```bash theme={"dark"} # 1. List all routes (mint a 1-hour OAuth token first — see /api-reference/authentication) TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) curl https://api.knoxcall.com/v1/routes \ -H "Authorization: Bearer $TOKEN" # 2. Verify route exists and is enabled # 3. Check route name matches exactly ``` ### 401 Unauthorized **Meaning:** Authentication failed #### Possible Causes **1. Invalid API key** ```bash theme={"dark"} # Check your API key -H "x-knoxcall-key: tk_abc123_xyz789..." ``` **Fixes:** * Copy key again from dashboard * Ensure no extra spaces or line breaks * Verify key hasn't been deleted * Check key hasn't expired **2. Missing API key** If route requires API key but none provided: ```bash theme={"dark"} # Add API key header -H "x-knoxcall-key: YOUR_API_KEY" ``` **3. Using wrong authentication method** Route configured for IP auth but API key provided (or vice versa). **Fix:** 1. Check route settings 2. Match authentication method 3. Either provide API key OR ensure IP is whitelisted #### Debug Steps ```bash theme={"dark"} # 1. Verify API key format (should be 72 chars starting with tk_) echo "YOUR_API_KEY" | wc -c # 2. Test API key validity (x-api-key is the management API's key header; # x-knoxcall-key is only for proxy data-plane requests) curl https://api.knoxcall.com/v1/account \ -H "x-api-key: YOUR_API_KEY" # 3. Check route authentication requirements ``` ### 403 Forbidden **Meaning:** IP not authorized or permission denied #### Possible Causes **1. IP not whitelisted** Your IP address isn't in the client list. **Fix:** 1. Navigate to **Clients** → **Add Client** 2. Add your IP address 3. Go to route → **Clients** tab 4. Assign client to appropriate environment **Quick test:** Disable IP requirement temporarily: 1. Edit route 2. Toggle **Requires Clients** to OFF 3. Test request 4. Re-enable after testing **2. Client not assigned to route** Client exists but isn't assigned to this route/environment. **Fix:** 1. Go to route → **Clients** tab 2. Add your client to the environment 3. Save **3. Rate limit exceeded** You've hit the rate limit. **Response:** ```http theme={"dark"} HTTP/1.1 429 Too Many Requests X-RateLimit-Remaining: 0 Retry-After: 3600 ``` **Fix:** * Wait for rate limit to reset * Request limit increase * Optimize request frequency #### Debug Steps ```bash theme={"dark"} # 1. Check your public IP curl https://api.ipify.org # 2. Verify IP is in client list # Go to Clients page → Search for your IP # 3. Check client assignment # Route → Clients tab → Verify client is listed ``` ### 500 Internal Server Error **Meaning:** KnoxCall encountered an error processing your request #### Possible Causes **1. Secret not found** Route tries to inject secret that doesn't exist: ```json theme={"dark"} { "Authorization": "Bearer {{secret:missing_key}}" } ``` **Fix:** 1. Navigate to **Secrets** 2. Create the missing secret 3. Update route configuration **2. Invalid secret format** Secret exists but has invalid format for use case. **Fix:** 1. Check secret value format 2. Update if needed 3. Test route again **3. Configuration error** Invalid JSON in header/body injection. **Fix:** 1. Edit route 2. Check JSON syntax in Header/Body Injection 3. Use online JSON validator 4. Save with valid JSON #### Debug Steps ```bash theme={"dark"} # 1. Check API logs # Go to Logs → API Logs → Find your request # Look for error details # 2. Verify secrets exist # Go to Secrets → Check all referenced secrets # 3. Validate configuration JSON # Copy Header/Body Injection JSON # Validate at jsonlint.com ``` ### 502 Bad Gateway **Meaning:** Backend server is unreachable or returned invalid response #### Possible Causes **1. Backend is down** Your backend server isn't running. **Test:** ```bash theme={"dark"} # Try reaching backend directly curl https://your-backend.com/health ``` **Fix:** * Start your backend server * Verify backend is running * Check backend logs **2. Wrong backend URL** Target URL in route settings is incorrect. **Fix:** 1. Edit route 2. Check **Target Base URL** 3. Verify URL is correct 4. Include protocol: `https://` 5. Test URL directly: `curl https://your-backend.com` **3. Backend not accessible from internet** Backend is running but not reachable from KnoxCall. **Common scenarios:** * Backend running on `localhost` * Backend behind firewall * Backend only accessible via VPN * Private IP address used **Fix:** * Deploy backend with public IP * Open firewall rules for KnoxCall IPs * Use VPN egress feature (Enterprise) **4. SSL/TLS issues** Backend has invalid SSL certificate. **Errors:** * Certificate expired * Self-signed certificate * Certificate domain mismatch **Fix:** * Renew SSL certificate * Use valid CA-signed certificate * For testing: Disable SSL verification in route settings (not recommended for production) **5. Backend returning non-HTTP response** Backend is running but returning invalid HTTP. **Fix:** * Check backend logs * Ensure backend returns proper HTTP response * Verify headers are correctly formatted #### Debug Steps ```bash theme={"dark"} # 1. Test backend directly curl -v https://your-backend.com/endpoint # 2. Check SSL certificate openssl s_client -connect your-backend.com:443 -servername your-backend.com # 3. Verify DNS resolution nslookup your-backend.com dig your-backend.com # 4. Test from KnoxCall's perspective # Use route testing tool in dashboard ``` ### 504 Gateway Timeout **Meaning:** Backend took too long to respond #### Possible Causes **1. Backend is slow** Backend processing time exceeds timeout. **Default timeout:** 30 seconds **Fix:** 1. Edit route 2. Increase **Timeout** setting 3. Recommended max: 120 seconds **Long-term fix:** * Optimize backend performance * Add caching * Use async processing for long operations **2. Backend is overloaded** Too many requests overwhelming backend. **Symptoms:** * Slow response times * High CPU/memory usage * Database connection pool exhausted **Fix:** * Scale backend (add more instances) * Optimize database queries * Add caching layer * Enable rate limiting **3. Database timeout** Backend waiting for slow database query. **Fix:** * Optimize SQL queries * Add database indexes * Increase database resources * Implement query timeout **4. Network latency** High network latency between KnoxCall and backend. **Fix:** * Move your backend closer to KnoxCall's hosting region (New York, US; [multi-region](/infrastructure/multi-region) is planned, not yet available) * Optimize network path * Consider CDN for static content #### Debug Steps ```bash theme={"dark"} # 1. Measure backend response time time curl https://your-backend.com/endpoint # 2. Check backend metrics # - CPU usage # - Memory usage # - Active connections # - Database query time # 3. Enable detailed logging on backend # Log each processing step with timestamps # 4. Increase timeout temporarily for testing # Route settings → Timeout: 60s ``` ## Authentication Issues ### "Invalid signature" errors **Using request signing?** **Causes:** * Wrong signing secret * Message format mismatch * Timestamp too old/new * Clock skew between client and server **Fix:** ```javascript theme={"dark"} // 1. Verify message format const message = `${timestamp}.${method}.${path}.${JSON.stringify(body)}`; // 2. Check timestamp is current const now = Math.floor(Date.now() / 1000); console.log('Current timestamp:', now); // 3. Verify secret is correct console.log('Using secret:', secret.substring(0, 10) + '...'); // 4. Log computed signature console.log('Computed signature:', signature); ``` ### OAuth2 token expired **Error:** `401 Unauthorized` from backend **Causes:** * Access token expired * Refresh token revoked * OAuth2 secret needs reauthorization **Fix:** 1. Go to **Secrets** → Select OAuth2 secret 2. Check token status 3. If expired, click **Reauthorize** 4. Complete OAuth2 flow again ## Connection Issues ### "Connection refused" **Causes:** * Backend not running * Wrong port number * Firewall blocking connections **Fix:** ```bash theme={"dark"} # 1. Check if backend is listening netstat -an | grep YOUR_PORT # 2. Test local connection curl http://localhost:YOUR_PORT/health # 3. Test from external IP curl http://YOUR_PUBLIC_IP:YOUR_PORT/health # 4. Check firewall rules iptables -L -n ``` ### "DNS resolution failed" **Causes:** * Domain doesn't exist * DNS not configured * DNS propagation not complete **Fix:** ```bash theme={"dark"} # 1. Verify domain exists nslookup your-backend.com # 2. Check DNS records dig your-backend.com # 3. Wait for DNS propagation (24-48 hours for new domains) # 4. Use IP address temporarily # Route → Target URL: https://203.0.113.50 ``` ### "SSL handshake failed" **Causes:** * Expired certificate * Self-signed certificate * Certificate hostname mismatch **Fix:** ```bash theme={"dark"} # 1. Test SSL openssl s_client -connect your-backend.com:443 # 2. Check certificate expiration echo | openssl s_client -connect your-backend.com:443 2>/dev/null | openssl x509 -noout -dates # 3. Verify certificate matches domain echo | openssl s_client -connect your-backend.com:443 2>/dev/null | openssl x509 -noout -subject ``` ## Performance Issues ### High latency **Symptoms:** * Requests taking longer than expected * Inconsistent response times **Diagnose:** ```text theme={"dark"} Total latency: 2,500ms ├─ KnoxCall processing: 50ms ├─ Network to backend: 150ms ├─ Backend processing: 2,200ms ← Problem here! └─ Network back: 100ms ``` **Check the response headers.** Every forwarded call carries them; see [Response metadata](/api-reference/response-metadata): * `X-Knox-Latency-Ms`: total time inside KnoxCall * `X-Knox-Upstream-Ms`: time spent waiting on your backend * `X-Knox-Overhead-Ms`: time KnoxCall itself spent, before and after the backend call **Or the log detail page.** The Proxy tab's **Proxy Latency** is the same overhead figure (total minus upstream), and the Timeline tab shows each stage: every badge there is the time since the previous step, and "Forwarded to Upstream" is stamped when the connection to your backend is actually opened. **Fix:** * If `X-Knox-Upstream-Ms` is high: optimize your backend * If `X-Knox-Overhead-Ms` is high: contact support ### Intermittent failures **Symptoms:** * Some requests succeed, others fail * No consistent pattern **Possible causes:** * Backend load balancer issues * Database connection pool exhausted * Memory leaks in backend * Network instability **Debug:** ```text theme={"dark"} 1. Check failure rate over time 2. Identify patterns (time of day, specific endpoints) 3. Review backend logs during failures 4. Monitor backend resource usage ``` ## Testing & Debugging Tools ### Use API Logs **Best debugging tool:** 1. Go to **Logs** → **API Logs** 2. Find your request 3. Expand to see: * Full request headers/body * Full response headers/body * Error messages * Latency breakdown ### Test in Dashboard Use built-in testing tool: 1. Go to Routes → Select route 2. Click **Test Route** 3. Configure request 4. See detailed response ### Use verbose curl ```bash theme={"dark"} # $TOKEN is a kc_… OAuth access token — see /api-reference/authentication # (legacy API keys go in x-knoxcall-key instead) curl -v https://a1b2c3d4.acme.knoxcall.com/api/test \ -H "Authorization: Bearer $TOKEN" \ -H "x-knoxcall-route: my-route" ``` The `-v` flag shows: * DNS resolution * Connection establishment * SSL handshake * Request headers sent * Response headers received ### Check Health Endpoint Test backend directly: ```bash theme={"dark"} # If your backend has a health endpoint curl https://your-backend.com/health # Expected: HTTP/1.1 200 OK {"status": "healthy"} ``` ## Getting Help If you're still stuck: ### 1. Gather Information Before contacting support: ```text theme={"dark"} - Route name - Tenant subdomain - Error message - Request timestamp - Expected behavior - Actual behavior - Steps to reproduce ``` ### 2. Check API Logs Find the failing request: 1. Go to Logs → API Logs 2. Copy Request ID 3. Include in support ticket ### 3. Export Configuration Export route configuration: 1. Go to Routes → Select route 2. Click **Export** 3. Attach to support ticket ### 4. Contact Support * **Email**: [support@knoxcall.com](mailto:support@knoxcall.com) * **Chat**: Click support icon in dashboard * **Phone**: Available for Enterprise customers ## Prevention Tips ### 1. Enable Health Checks Monitor route health: 1. Edit route 2. Enable **Health Checks** 3. Set check interval: 60s 4. Set alert threshold ### 2. Set Up Alerts Get notified of issues: ```text theme={"dark"} Alert: Route Error Rate > 5% Channels: Email, Slack ``` ### 3. Use Staging Environment Test changes before production: ```text theme={"dark"} 1. Create staging environment 2. Test route changes in staging 3. Verify everything works 4. Deploy to production ``` ### 4. Monitor Regularly Check dashboard weekly: * Success rates * Latency trends * Error patterns * Usage statistics ### 5. Keep Backend Logs Maintain backend logs for debugging: * HTTP request/response * Processing time * Error messages * Stack traces ## Next Steps View detailed request logs Set up error alerts Learn route testing Get help from support *** * **Level**: beginner to advanced * **Time**: Reference guide `troubleshooting`, `debugging`, `errors`, `fixes`, `support` # Troubleshooting: IP Authorization Issues Source: https://docs.knoxcall.com/troubleshooting/ip-authorization-issues The #1 most common KnoxCall problem and how to fix it. Debug 'IP not authorized' errors quickly. # Troubleshooting: IP Authorization Issues **"IP address not authorized for this route"** is the **#1 most common error** new KnoxCall users encounter. This guide will help you fix it quickly. ## Symptoms You make a request and receive: ```json theme={"dark"} { "error": "IP address not authorized for this route", "status": 403, "ip": "203.45.67.89" } ``` Or: ```json theme={"dark"} { "error": "Client authorization required but IP not found", "status": 403 } ``` **What this means**: KnoxCall received your request but your IP address isn't on the authorized client list for this route. *** ## Quick Fix (Most Common Cause) ### Problem: Your IP Changed **This is the #1 cause** (especially for development machines). Your home/office internet likely uses **dynamic IP addresses** that change periodically. ### Solution: Update Your Client IP **Step 1: Check your current IP** ```bash theme={"dark"} curl https://api.ipify.org ``` Output: `203.45.67.89` **Step 2: Update your client in KnoxCall** 1. Go to **Resources** → **Clients** 2. Find your client (e.g., "john-dev-laptop") 3. Click **Edit** 4. Update IP to: `203.45.67.89` 5. Click **Save** **Step 3: Try your request again** ✅ Should work now! *** ## Common Causes & Solutions ### 1. Client Not Created **Symptoms:** * Fresh KnoxCall account * Never created any clients * Getting IP authorization errors **Cause:** You haven't whitelisted any IPs yet. **Solution:** 1. Navigate to **Resources** → **Clients** 2. Click **Create Client** 3. Fill in: * **Name**: `my-dev-machine` * **Type**: User * **IP**: Your IP from `curl https://api.ipify.org` 4. Click **Save** 5. **Assign client to route** (see Cause #2) *** ### 2. Client Not Assigned to Route **Symptoms:** * Client exists in KnoxCall * IP matches * Still getting 403 errors **Cause:** Client exists, but it's not assigned to this specific route. **How to check:** 1. Go to **Routes** → Find your route 2. Click on the route 3. Go to **Clients** tab 4. Select your environment (e.g., "production") 5. Check: Is your client listed? **Solution:** 1. Click **Assign Clients** 2. Check the box next to your client 3. Click **Save** ✅ Client is now authorized for this route! *** ### 3. Wrong Environment **Symptoms:** * Client is assigned to route * IP is correct * Still getting 403 errors **Cause:** You're calling one environment, but your client is assigned to a different one. **Example:** ```bash theme={"dark"} # Calling production environment curl https://a1b2c3d4.acme.knoxcall.com/api/users \ -H "x-knoxcall-route: user-api" \ -H "x-knoxcall-environment: production" ``` But your client is only assigned to **staging** environment! **Solution:** **Option A: Assign to correct environment** 1. Go to route → **Clients** tab 2. Select "production" 3. Click **Assign Clients** 4. Check your client 5. Save **Option B: Use correct environment in request** ```bash theme={"dark"} curl https://a1b2c3d4.acme.knoxcall.com/api/users \ -H "x-knoxcall-route: user-api" \ -H "x-knoxcall-environment: staging" # Changed to staging ``` *** ### 4. Client Disabled **Symptoms:** * Everything looks correct * Used to work * Suddenly stopped working **Cause:** Someone disabled the client. **How to check:** 1. Go to **Resources** → **Clients** 2. Find your client 3. Look at the **Enabled** column 4. Is it showing **OFF**? **Solution:** 1. Click on the client 2. Toggle **Enabled** to **ON** 3. Click **Save** *** ### 5. IP Detection Behind Proxy/CDN **Symptoms:** * Your actual IP is correct in clients list * Still getting 403 with different IP shown in error **Cause:** You're behind a proxy (Cloudflare, Nginx, etc.) and KnoxCall is seeing the proxy IP, not your real IP. **Example error:** ```json theme={"dark"} { "error": "IP not authorized", "ip": "104.21.45.67" // This is Cloudflare's IP, not yours! } ``` **Solution:** KnoxCall automatically detects IPs from these headers (in order): 1. `cf-connecting-ip` (Cloudflare) 2. `x-forwarded-for` (Standard) 3. Socket remote address (fallback) **If still wrong, whitelist the proxy IP:** ```text theme={"dark"} Client IP: 104.21.45.67 (the IP shown in error) ``` Or better, **use API key authentication** (see Cause #7). *** ### 6. CIDR Notation Mistake **Symptoms:** * Using IP range (CIDR notation) * Some IPs work, some don't **Cause:** CIDR range doesn't cover your actual IP. **Example:** ```text theme={"dark"} Client IP: 192.168.1.0/24 Your actual IP: 192.168.2.50 ``` `/24` covers `192.168.1.0` to `192.168.1.255` only. Your IP `192.168.2.50` is outside this range! **Solution:** **Option A: Expand range** ```text theme={"dark"} 192.168.0.0/16 // Covers 192.168.0.0 to 192.168.255.255 ``` **Option B: Create separate client** ```text theme={"dark"} Client 1: 192.168.1.0/24 (Office network A) Client 2: 192.168.2.0/24 (Office network B) ``` **CIDR Quick Reference:** * `/32` = 1 IP (e.g., `192.168.1.1` only) * `/24` = 256 IPs (e.g., `192.168.1.0` to `192.168.1.255`) * `/16` = 65,536 IPs (e.g., `192.168.0.0` to `192.168.255.255`) *** ### 7. Dynamic IP (Home Internet) **Symptoms:** * Works sometimes * Stops working randomly * Home/office internet * Need to keep updating IP **Cause:** Your ISP assigns dynamic IPs that change every few hours/days. **Solution: Authenticate with a credential instead of an IP** Instead of IP allowlisting, authenticate with a credential that isn't tied to your address. Short-lived OAuth access tokens are the modern path — see [Authentication](/api-reference/authentication). If you're using legacy API keys: **Step 1: Create test API key** 1. Go to **Settings** → **Tenant** tab → **API Keys** 2. Click **Create API Key** 3. Name: `dev-test-key` 4. **Check**: Allow any IP ✓ 5. Copy the key: `tk_abc123...` **Step 2: Use in requests** ```bash theme={"dark"} curl https://a1b2c3d4.acme.knoxcall.com/api/users \ -H "x-knoxcall-key: tk_abc123..." \ -H "x-knoxcall-route: user-api" ``` No more IP changes to worry about! *** ### 8. Route Has `requires_clients` Disabled **Symptoms:** * Set up clients * Assigned to route * Still getting errors OR anyone can access **Cause:** The route's `requires_clients` flag is OFF. **How to check:** 1. Go to **Routes** → Your route 2. Look for **Requires Clients** setting 3. Is it **OFF**? **What this means:** * **OFF** = Anyone can access (no IP check) * **ON** = Only whitelisted clients can access **Solution:** 1. Edit route 2. Toggle **Requires Clients** to **ON** 3. Save *** ## Debugging Checklist Follow this checklist step-by-step: ### ✅ Step 1: Confirm Your IP ```bash theme={"dark"} curl https://api.ipify.org ``` Write it down: `____________________` ### ✅ Step 2: Check Client Exists * [ ] Go to **Resources** → **Clients** * [ ] Your IP is listed? (Exact match or in CIDR range) * [ ] Client is **Enabled** (toggle ON)? ### ✅ Step 3: Check Client Assignment * [ ] Go to **Routes** → Your route * [ ] Click **Clients** tab * [ ] Select environment (production/staging/dev) * [ ] Your client is listed? ### ✅ Step 4: Check Route Settings * [ ] Route has **Requires Clients** = ON? * [ ] Route is **Enabled** = ON? ### ✅ Step 5: Verify Request Headers ```bash theme={"dark"} # Are you sending the correct route name? -H "x-knoxcall-route: YOUR_ROUTE_NAME" # Are you specifying correct environment? -H "x-knoxcall-environment: production" # Optional ``` ### ✅ Step 6: Check Logs 1. Go to **Logs** → **API Logs** 2. Find your failed request 3. Look at error details 4. Check IP shown vs your actual IP *** ## Quick Tests ### Test 1: Temporarily Disable IP Check To confirm IP authorization is the problem: 1. Edit your route 2. Toggle **Requires Clients** to **OFF** 3. Save 4. Try your request again **If it works now:** ✅ Confirmed: IP authorization was the issue ❌ If still fails: Different problem (not IP-related) **Remember to turn `Requires Clients` back ON for production!** ### Test 2: Use Test API Key Create an API key that bypasses IP check: 1. **Settings** → **Tenant** tab → **API Keys** 2. **Create API Key** 3. Name: `bypass-test-key` 4. **Allow any IP**: ✓ 5. Use this key in requests: ```bash theme={"dark"} curl https://a1b2c3d4.acme.knoxcall.com/api/test \ -H "x-knoxcall-key: tk_test_bypass..." \ -H "x-knoxcall-route: user-api" ``` **If this works**: Definitely an IP whitelisting issue. *** ## Advanced Scenarios ### VPN Users **Problem:** Your IP changes when VPN connects/disconnects. **Solution A: Whitelist VPN IP range** ```text theme={"dark"} Client IP: 10.8.0.0/16 (entire VPN network) ``` **Solution B: Use API key auth** (See Cause #7 above) ### AWS/Cloud Servers **Problem:** EC2 instances with dynamic IPs. **Solution: Use Elastic IP** 1. Allocate Elastic IP in AWS 2. Assign to your EC2 instance 3. Use Elastic IP in KnoxCall client 4. IP never changes! **Cost:** \~\$3.60/month for unused Elastic IP (free when attached to running instance) ### Docker/Kubernetes **Problem:** Container IPs change on restart. **Solution: Use NAT gateway or API key auth** **Don't whitelist**: Container internal IPs (172.17.0.2, etc.) **Do whitelist**: NAT gateway public IP or load balancer IP ### Multiple Developers **Problem:** 10 developers, each with changing IPs. **Solution: Use test API keys per developer** ```text theme={"dark"} API Key: dev-john (john@acme.com) API Key: dev-jane (jane@acme.com) API Key: dev-bob (bob@acme.com) ``` Each developer gets their own key, no IP management needed. *** ## Prevention Tips ### For Development 1. **Use API key auth** instead of IP whitelisting 2. Create separate test keys for each developer 3. Enable "Allow any IP" for dev keys only ### For Production 1. **Use static IPs** (Elastic IPs, static VPS IPs) 2. **Document your IPs** with descriptions 3. **Audit clients monthly** - remove unused ones 4. **Use CIDR ranges** for networks, not individual IPs ### For Staging 1. **Separate clients** from production 2. **Allow broader IP ranges** for easier testing 3. **Use descriptive names** (staging-server-1, not server1) *** ## Error Message Reference | Error Message | Meaning | Most Common Cause | | - | - | - | | `IP address not authorized for this route` | Your IP isn't whitelisted | IP changed or not assigned to route | | `Client authorization required but IP not found` | No matching client | Never created a client | | `Route requires client authorization` | Route has IP check enabled | Forgot to assign client | | `No clients assigned to environment` | Environment has no clients | Assigned to wrong environment | *** ## Still Having Issues? ### Check These Less Common Causes 1. **Firewall blocking**: Your firewall is changing source IP 2. **IPv4 vs IPv6**: Using IPv6 but whitelisted IPv4 (or vice versa) 3. **Rate limiting**: Hit rate limit, getting different error that looks similar 4. **Expired API key**: Using old/deleted API key 5. **Wrong subdomain**: Using wrong tenant subdomain ### Get Help If none of these solutions work: Fastest way to get help Review detailed request logs Deep dive into how clients work Other error codes and solutions *** ## Summary: Most Common Fixes **90% of IP authorization issues are one of these:** 1. ✅ **IP changed** → Update client IP 2. ✅ **Client not assigned to route** → Assign in Clients tab 3. ✅ **Wrong environment** → Check which environment you're calling 4. ✅ **Dynamic IP** → Use API key auth instead **Quick fix for development:** Use API key with "Allow any IP" enabled. No more IP hassles! *** **Security Note**: Only use "Allow any IP" API keys for development/testing. Production keys should always validate IP addresses for security. # Troubleshooting 504 Gateway Timeout Errors Source: https://docs.knoxcall.com/troubleshooting/troubleshooting-504-errors Learn how to diagnose and fix 504 Gateway Timeout errors. Common causes and solutions included. # Troubleshooting 504 Gateway Timeout Getting 504 errors? This guide will help you diagnose and fix timeout issues. ## What is a 504 Error? A 504 Gateway Timeout occurs when KnoxCall doesn't receive a response from your upstream service within the timeout period (default: 30 seconds). ## Common Causes ### 1. Slow Upstream Service Your backend service is taking too long to respond. **Solution:** * Check your backend service logs * Optimize slow database queries * Add caching for expensive operations * Scale your backend infrastructure ### 2. Network Issues Network connectivity problems between KnoxCall and your backend. **Solution:** * Verify your target URL is correct * Check firewall rules * Test connectivity: `curl your-backend-url` * Ensure your backend is accessible from the internet ### 3. Timeout Too Short The default 30s timeout may be too short for your use case. **Solution:** * Contact support to increase timeout limits * Consider async processing for long-running tasks * Use webhooks for callbacks instead of long-polling ### 4. Backend Service Down Your upstream service may be unavailable. **Solution:** * Check if your backend is running * Review your backend service health checks * Check your hosting provider status page ## Debugging Steps 1. **Check Recent Errors** * Go to **Logs** in the dashboard * Filter by status code: 504 * Look for patterns in timing 2. **Test Direct Connection** ```bash theme={"dark"} curl -v https://your-backend.com/api/endpoint ``` 3. **Check Alerts** * Review any triggered alerts * Look for error rate spikes 4. **Review Metrics** * Check P95/P99 latency * Identify slow endpoints ## Prevention ### Set Up Alerts Configure alerts for high latency: * Threshold: P95 > 5 seconds * Action: Get notified immediately ### Add Health Checks Implement health check endpoints: ```javascript theme={"dark"} app.get("/health", (req, res) => { res.json({ status: "healthy" }); }); ``` ### Use Caching Add caching for frequently accessed data: * Redis cache * CDN for static content * Application-level caching ### Optimize Queries * Add database indexes * Use query pagination * Implement connection pooling ## Still Having Issues? If you're still experiencing 504 errors: 1. Check the support chat 2. Create a support ticket with: * Route name * Timestamp of errors * Sample requests that fail 3. Contact support for timeout limit increases *** * **Views**: 2 * **Helpful**: 0 👍 * **Level**: intermediate `errors`, `504`, `timeout`, `debugging` # Creating Webhooks Source: https://docs.knoxcall.com/webhooks/creating-webhooks Step-by-step guide to creating and configuring webhooks in KnoxCall. Set up event subscriptions, authentication, and payload options. # Creating Webhooks This guide walks you through creating a webhook to receive real-time notifications when API requests pass through your routes. ## Prerequisites Before creating a webhook, you'll need: 1. A KnoxCall account with at least one route 2. An endpoint URL that can receive HTTP POST requests 3. (Optional) Authentication credentials for your endpoint ## Step 1: Navigate to Webhooks 1. Open the KnoxCall admin dashboard 2. Click **Automation** in the sidebar, then select **Webhooks** 3. Click **+ Create Webhook** ## Step 2: Basic Configuration ### Name and Description Give your webhook a descriptive name: ```text theme={"dark"} Name: Slack Error Notifications Description: Send Slack alerts when payment routes return 5xx errors ``` **Best practices:** * Use descriptive names that explain the purpose * Include the destination in the name (e.g., "Slack", "Zapier", "Datadog") * Add context in the description ### Webhook URL Enter the endpoint URL where KnoxCall will send webhook events: ```text theme={"dark"} URL: https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXX ``` **Supported protocols:** * `https://` (recommended) * `http://` (for development only) **Use HTTPS in production.** HTTP endpoints may expose sensitive data in transit. ### HTTP Method Choose the HTTP method for webhook requests: | Method | Use Case | | - | - | | **POST** (default) | Most webhook endpoints | | **PUT** | Some REST APIs that expect PUT | | **PATCH** | Partial updates | | **GET** | Query-based webhooks (payload in query params) | ## Step 3: Event Types Select which events should trigger this webhook: ### Request Lifecycle Events Triggers when a request first arrives at KnoxCall, **before** forwarding to the backend. **Use cases:** * Real-time request monitoring * Request logging * Pre-flight webhooks Triggers when the backend returns a **2xx** response. **Use cases:** * Success tracking * Post-transaction webhooks * Analytics Triggers when the backend returns a **3xx** response. **Use cases:** * Redirect monitoring * SEO tracking Triggers when the backend returns a **4xx** response. **Use cases:** * Invalid request tracking * Authentication failure alerts * Rate limit notifications Triggers when the backend returns a **5xx** response. **Use cases:** * Error alerting (Slack, PagerDuty) * Incident management * Backend health monitoring Triggers when the request times out before receiving a response. **Use cases:** * Timeout monitoring * Performance alerts * SLA tracking Triggers on **any non-2xx** response (4xx, 5xx, or timeout). **Use cases:** * General error monitoring * Catch-all error handler Triggers on **every** completed request, regardless of status. **Use cases:** * Comprehensive logging * Analytics * Audit trails **Example selection for error alerting:** ```text theme={"dark"} Selected Events: ✓ request.server_error ✓ request.timeout ``` ## Step 4: Route Filtering (Optional) By default, webhooks trigger for **all routes** in your tenant. To limit to specific routes: 1. Toggle on **Route Filter** 2. Select the routes you want to monitor **Example:** ```text theme={"dark"} Route Filter: ✓ payment-api ✓ checkout-service ✗ internal-admin (excluded) ``` Leave route filter empty to trigger for all routes. This is useful for logging or analytics webhooks. ## Step 5: Authentication Configure how KnoxCall authenticates with your webhook endpoint: ### No Authentication ```text theme={"dark"} Auth Type: None ``` Use for endpoints that don't require authentication (e.g., some Slack webhooks). ### Bearer Token ```text theme={"dark"} Auth Type: Bearer Token: your-secret-token-here ``` KnoxCall sends: ```http theme={"dark"} Authorization: Bearer your-secret-token-here ``` ### Basic Authentication ```text theme={"dark"} Auth Type: Basic Username: webhook_user Password: secret_password ``` KnoxCall sends: ```http theme={"dark"} Authorization: Basic d2ViaG9va191c2VyOnNlY3JldF9wYXNzd29yZA== ``` ### Custom Header ```text theme={"dark"} Auth Type: Header Header Name: X-API-Key Header Value: your-api-key-here ``` KnoxCall sends: ```http theme={"dark"} X-API-Key: your-api-key-here ``` ### HMAC Signature (Always Included) Regardless of auth type, every webhook includes an HMAC signature: ```http theme={"dark"} X-Webhook-Signature: sha256=abc123def456... ``` Use this to verify the webhook came from KnoxCall. ## Step 6: Payload Options Control what data is included in the webhook payload: ### Include Request Body **Default: Yes** When enabled, includes the original request body: ```json theme={"dark"} { "data": { "request": { "body": { "amount": 1000, "currency": "usd" } } } } ``` **Disable for:** * Large payloads that would slow down delivery * Sensitive data you don't want in logs ### Include Response Body **Default: Yes** When enabled, includes the backend response body: ```json theme={"dark"} { "data": { "response": { "body": { "id": "ch_123", "status": "succeeded" } } } } ``` **Disable for:** * Large response payloads * Sensitive response data ### Include Headers **Default: No** When enabled, includes request and response headers: ```json theme={"dark"} { "data": { "request": { "headers": { "content-type": "application/json", "user-agent": "..." } }, "response": { "headers": { "content-type": "application/json" } } } } ``` Sensitive headers (Authorization, API keys, cookies) are automatically **redacted** when included. ### Include Status Code **Default: Yes** When enabled, includes the HTTP status code: ```json theme={"dark"} { "data": { "response": { "status": 200 } } } ``` ## Step 7: Retry Configuration Configure how KnoxCall handles failed deliveries: ### Retry on Failure **Default: Yes** When enabled, KnoxCall retries failed webhook deliveries. **Failure conditions:** * Connection timeout * HTTP 5xx response from your endpoint * Network errors HTTP 4xx responses are **not** retried, as they typically indicate a client error. ### Max Retries **Default: 3** (Range: 0-5) Number of retry attempts after the initial failure. **Retry schedule:** | Attempt | Delay | | - | - | | 1 (initial) | Immediate | | 2 | 1 second | | 3 | 2 seconds | | 4 | 4 seconds | | 5 | 8 seconds | ### Timeout **Default: 30 seconds** (Range: 1-60) How long to wait for a response from your webhook endpoint before timing out. **Recommendations:** * **Slack/Zapier:** 10-15 seconds * **Custom endpoints:** 30 seconds * **Slow backends:** Up to 60 seconds ## Step 8: Custom Headers (Optional) Add additional HTTP headers to webhook requests: ```json theme={"dark"} { "X-Custom-Header": "my-value", "X-Environment": "production", "X-Source": "knoxcall" } ``` **Use cases:** * Identify the source of webhooks * Pass environment information * Custom routing in your endpoint ## Step 9: Enable and Save 1. Toggle **Enabled** to ON (default) 2. Click **Create Webhook** Your webhook is now active and will trigger on the configured events! ## Testing Your Webhook After creating, test your webhook: 1. Click the **Test** button on the webhook detail page 2. KnoxCall sends a test payload with `event: webhook.test` 3. View the result: * **Success:** HTTP 2xx response * **Failure:** Error message and response details **Test payload example:** ```json theme={"dark"} { "event": "webhook.test", "timestamp": "2025-01-15T10:30:45.123Z", "test": true, "data": { "route_id": "00000000-0000-0000-0000-000000000000", "route_name": "Test Route", "request": { "method": "POST", "path": "/api/test" }, "response": { "status": 200, "latency_ms": 42 } } } ``` ## Example Configurations ### Slack Error Alerts ```yaml theme={"dark"} Name: Slack Payment Errors URL: https://hooks.slack.com/services/... Method: POST Events: request.server_error, request.timeout Auth: None Payload: Include Request Body: No Include Response Body: Yes Include Headers: No Retry: Yes (3 attempts) Timeout: 10 seconds ``` ### Analytics Logging ```yaml theme={"dark"} Name: Analytics Full Logging URL: https://analytics.yourcompany.com/ingest Method: POST Events: request.completed Route Filter: (all routes) Auth: Bearer token Payload: Include Request Body: Yes Include Response Body: Yes Include Headers: Yes Retry: Yes (5 attempts) Timeout: 30 seconds ``` ### Zapier Integration ```yaml theme={"dark"} Name: Zapier Payment Success URL: https://hooks.zapier.com/hooks/catch/123456/abcdef Method: POST Events: request.success Route Filter: payment-api only Auth: None Payload: Include Request Body: Yes Include Response Body: Yes Include Headers: No Retry: Yes (3 attempts) Timeout: 15 seconds ``` ## Managing Your Signing Secret Each webhook has a unique signing secret for HMAC verification: ### View Secret 1. Open webhook detail page 2. Click **Reveal Secret** 3. Copy the secret for use in your verification code ### Regenerate Secret 1. Open webhook detail page 2. Click **Regenerate Secret** 3. Copy the new secret immediately 4. Update your verification code **Regenerating invalidates the old secret immediately.** Make sure to update your verification code before regenerating. ## Next Steps Verify your webhook configuration Monitor webhook deliveries Secure your webhook endpoint Learn about webhook concepts *** * **Level**: beginner * **Time**: 10 minutes `webhooks`, `configuration`, `setup`, `events`, `authentication` # Testing Webhooks Source: https://docs.knoxcall.com/webhooks/testing-webhooks Learn how to test your webhook configuration before going live. Use the built-in test feature and verify your endpoint receives data correctly. # Testing Webhooks Before relying on webhooks in production, test them to ensure your endpoint receives and processes data correctly. ## Built-in Test Feature KnoxCall provides a one-click test feature for every webhook: ### How to Test 1. Click **Automation** in the sidebar, then select **Webhooks** 2. Click on your webhook 3. Click the **Test** button 4. View the results ### What Happens When you click Test, KnoxCall: 1. Sends a test payload to your endpoint 2. Includes all configured headers and authentication 3. Signs the payload with your webhook secret 4. Records the response (or error) 5. Shows you the result ### Test Payload The test payload includes a special `test: true` flag: ```json theme={"dark"} { "event": "webhook.test", "timestamp": "2025-01-15T10:30:45.123Z", "webhook_id": "wh_abc123", "webhook_name": "My Webhook", "test": true, "data": { "route_id": "00000000-0000-0000-0000-000000000000", "route_name": "Test Route", "request": { "method": "POST", "path": "/api/test", "headers": { "content-type": "application/json" }, "body": { "example": "data" } }, "response": { "status": 200, "headers": { "content-type": "application/json" }, "body": { "success": true }, "latency_ms": 42 } } } ``` ### Test Results **Success:** ```text theme={"dark"} Status: 200 OK Response Time: 145ms Response Body: {"received": true} ``` **Failure:** ```text theme={"dark"} Status: Failed Error: Connection timeout after 30000ms ``` ## Testing Locally To test webhooks with a local development server: ### Using ngrok 1. Install ngrok: `npm install -g ngrok` 2. Start your local server: `node server.js` (port 3000) 3. Create a tunnel: `ngrok http 3000` 4. Copy the HTTPS URL: `https://abc123.ngrok.io` 5. Use this URL in your webhook configuration ### Using webhook.site For quick testing without running a server: 1. Go to [webhook.site](https://webhook.site) 2. Copy your unique URL 3. Use it as your webhook endpoint 4. Watch requests arrive in real-time ## Verifying Test Requests On your endpoint, verify that test requests: ### 1. Arrive with Correct Headers ```http theme={"dark"} Content-Type: application/json X-Webhook-Signature: sha256=abc123... X-Webhook-ID: wh_abc123 X-Webhook-Event: webhook.test User-Agent: KnoxCall-Webhook/1.0 ``` ### 2. Include Valid Signature ```javascript theme={"dark"} const crypto = require('crypto'); app.post('/webhook', (req, res) => { const signature = req.headers['x-webhook-signature']; const payload = JSON.stringify(req.body); const expected = 'sha256=' + crypto .createHmac('sha256', process.env.WEBHOOK_SECRET) .update(payload) .digest('hex'); if (signature === expected) { console.log('Signature valid!'); } else { console.log('Signature invalid!'); } res.status(200).json({ received: true }); }); ``` ### 3. Contain Expected Data ```javascript theme={"dark"} app.post('/webhook', (req, res) => { const { event, test, data } = req.body; if (test) { console.log('This is a test webhook'); } console.log('Event:', event); console.log('Route:', data.route_name); console.log('Status:', data.response.status); res.status(200).json({ received: true }); }); ``` ## Testing Different Scenarios ### Test Success Events 1. Create a route pointing to a working API 2. Make a successful request through the route 3. Verify your webhook receives `request.success` event ### Test Error Events 1. Create a route pointing to a non-existent URL 2. Make a request through the route 3. Verify your webhook receives `request.server_error` event ### Test Timeout Events 1. Create a route with a short timeout (5 seconds) 2. Point it to a slow API 3. Verify your webhook receives `request.timeout` event ## Troubleshooting Test Failures ### "Connection refused" **Cause:** Your endpoint isn't running or isn't accessible. **Fix:** * Verify your server is running * Check the URL is correct (including port) * Ensure firewall allows incoming connections ### "Connection timeout" **Cause:** Your endpoint is too slow or unreachable. **Fix:** * Check network connectivity * Ensure endpoint responds within timeout (default: 30s) * Reduce processing time in your handler ### "SSL certificate error" **Cause:** Invalid or self-signed certificate. **Fix:** * Use a valid SSL certificate * For testing, use HTTP (not recommended for production) * Use a service like Let's Encrypt for free certificates ### "401 Unauthorized" **Cause:** Authentication credentials are incorrect. **Fix:** * Verify Bearer token is correct * Check Basic auth username/password * Ensure custom header name/value are correct ### "Signature mismatch" **Cause:** You're verifying with the wrong secret. **Fix:** * Copy the secret from KnoxCall webhook settings * Ensure you're using the raw JSON body (not parsed) * Check for encoding issues ## Best Practices ### 1. Always Test Before Production Run the built-in test before enabling webhooks for real traffic. ### 2. Check Webhook Logs After testing, review the webhook logs to see: * What was sent * What response was received * Any error messages ### 3. Test All Event Types If you subscribe to multiple events, test scenarios that trigger each one. ### 4. Verify Signature Handling Ensure your endpoint correctly validates signatures before processing data. ### 5. Test Retry Behavior Intentionally return an error to verify retries work as expected. ## Next Steps Secure your webhook endpoint View test results in logs Configure webhook settings Learn about webhooks *** * **Level**: beginner * **Time**: 5 minutes `webhooks`, `testing`, `debugging`, `development` # Verifying Webhook Signatures Source: https://docs.knoxcall.com/webhooks/verifying-signatures Learn how to verify HMAC-SHA256 signatures on incoming webhooks to ensure they came from KnoxCall and weren't tampered with. # Verifying Webhook Signatures Every webhook from KnoxCall includes an HMAC-SHA256 signature. Verifying this signature ensures the webhook: 1. **Came from KnoxCall** (not an attacker) 2. **Wasn't modified** in transit ## How Signatures Work When KnoxCall sends a webhook: 1. Takes the JSON payload 2. Signs it with your webhook's secret key using HMAC-SHA256 3. Includes the signature in the `X-Webhook-Signature` header ```text theme={"dark"} X-Webhook-Signature: sha256=a1b2c3d4e5f6... ``` Your endpoint should: 1. Get the signature from the header 2. Compute the expected signature using the same secret 3. Compare them (timing-safe comparison) 4. Reject if they don't match ## Implementation Examples ### Node.js (Express) ```javascript theme={"dark"} const crypto = require('crypto'); const express = require('express'); const app = express(); // IMPORTANT: Use raw body for signature verification app.use('/webhook', express.raw({ type: 'application/json' })); const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET; function verifySignature(payload, signature) { const expected = 'sha256=' + crypto .createHmac('sha256', WEBHOOK_SECRET) .update(payload) .digest('hex'); // Use timing-safe comparison to prevent timing attacks return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expected) ); } app.post('/webhook', (req, res) => { const signature = req.headers['x-webhook-signature']; if (!signature) { return res.status(401).json({ error: 'Missing signature' }); } if (!verifySignature(req.body, signature)) { return res.status(401).json({ error: 'Invalid signature' }); } // Signature valid - process the webhook const event = JSON.parse(req.body); console.log('Received event:', event.event); res.status(200).json({ received: true }); }); app.listen(3000); ``` ### Python (Flask) ```python theme={"dark"} import hmac import hashlib from flask import Flask, request, jsonify app = Flask(__name__) WEBHOOK_SECRET = os.environ.get('WEBHOOK_SECRET') def verify_signature(payload, signature): expected = 'sha256=' + hmac.new( WEBHOOK_SECRET.encode(), payload, hashlib.sha256 ).hexdigest() return hmac.compare_digest(signature, expected) @app.route('/webhook', methods=['POST']) def webhook(): signature = request.headers.get('X-Webhook-Signature') if not signature: return jsonify({'error': 'Missing signature'}), 401 if not verify_signature(request.data, signature): return jsonify({'error': 'Invalid signature'}), 401 # Signature valid - process the webhook event = request.json print(f"Received event: {event['event']}") return jsonify({'received': True}), 200 if __name__ == '__main__': app.run(port=3000) ``` ### PHP ```php theme={"dark"} 'Missing signature']); exit; } if (!verifySignature($payload, $signature, $webhookSecret)) { http_response_code(401); echo json_encode(['error' => 'Invalid signature']); exit; } // Signature valid - process the webhook $event = json_decode($payload, true); error_log("Received event: " . $event['event']); http_response_code(200); echo json_encode(['received' => true]); ``` ### Go ```go theme={"dark"} package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "io" "net/http" "os" ) var webhookSecret = os.Getenv("WEBHOOK_SECRET") func verifySignature(payload []byte, signature string) bool { mac := hmac.New(sha256.New, []byte(webhookSecret)) mac.Write(payload) expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expected), []byte(signature)) } func webhookHandler(w http.ResponseWriter, r *http.Request) { signature := r.Header.Get("X-Webhook-Signature") if signature == "" { http.Error(w, `{"error":"Missing signature"}`, http.StatusUnauthorized) return } payload, _ := io.ReadAll(r.Body) if !verifySignature(payload, signature) { http.Error(w, `{"error":"Invalid signature"}`, http.StatusUnauthorized) return } // Signature valid - process the webhook var event map[string]interface{} json.Unmarshal(payload, &event) w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"received":true}`)) } func main() { http.HandleFunc("/webhook", webhookHandler) http.ListenAndServe(":3000", nil) } ``` ### Ruby (Sinatra) ```ruby theme={"dark"} require 'sinatra' require 'json' require 'openssl' WEBHOOK_SECRET = ENV['WEBHOOK_SECRET'] def verify_signature(payload, signature) expected = 'sha256=' + OpenSSL::HMAC.hexdigest( 'sha256', WEBHOOK_SECRET, payload ) Rack::Utils.secure_compare(expected, signature) end post '/webhook' do request.body.rewind payload = request.body.read signature = request.env['HTTP_X_WEBHOOK_SIGNATURE'] unless signature halt 401, { error: 'Missing signature' }.to_json end unless verify_signature(payload, signature) halt 401, { error: 'Invalid signature' }.to_json end # Signature valid - process the webhook event = JSON.parse(payload) puts "Received event: #{event['event']}" { received: true }.to_json end ``` ## Getting Your Webhook Secret ### View Secret 1. Click **Automation** in the sidebar, then select **Webhooks** 2. Click on your webhook 3. Click **Reveal Secret** 4. Copy the secret The secret looks like: `a1b2c3d4e5f6...` (64 hex characters) ### Regenerate Secret If your secret is compromised: 1. Click **Regenerate Secret** 2. Copy the new secret immediately 3. Update your endpoint with the new secret **Regenerating invalidates the old secret immediately.** Update your endpoint before regenerating, or webhooks will fail validation. ## Common Mistakes ### 1. Parsing JSON Before Verification **Wrong:** ```javascript theme={"dark"} // DON'T DO THIS app.use(express.json()); app.post('/webhook', (req, res) => { // req.body is already parsed - signature will fail! const signature = computeSignature(JSON.stringify(req.body)); }); ``` **Right:** ```javascript theme={"dark"} // Use raw body for webhook routes app.use('/webhook', express.raw({ type: 'application/json' })); app.post('/webhook', (req, res) => { // req.body is the raw string const signature = computeSignature(req.body); }); ``` ### 2. Wrong Secret **Symptoms:** * All signatures fail * No intermittent issues **Fix:** * Copy secret directly from KnoxCall * Ensure no extra whitespace * Check environment variable is set ### 3. Non-Timing-Safe Comparison **Wrong:** ```javascript theme={"dark"} // DON'T DO THIS - vulnerable to timing attacks if (signature === expected) { ... } ``` **Right:** ```javascript theme={"dark"} // Use timing-safe comparison if (crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) { ... } ``` ### 4. Encoding Issues **Symptoms:** * Signatures fail for certain payloads * Works for simple data, fails for special characters **Fix:** * Use raw bytes, not decoded strings * Ensure UTF-8 encoding throughout ## Security Best Practices ### 1. Always Verify Signatures Never process webhooks without signature verification: ```javascript theme={"dark"} if (!verifySignature(payload, signature)) { // Log the attempt for security monitoring console.warn('Invalid webhook signature from:', req.ip); return res.status(401).json({ error: 'Invalid signature' }); } ``` ### 2. Store Secrets Securely * Use environment variables (not hardcoded) * Rotate secrets periodically * Use secrets management services in production ### 3. Reject Missing Signatures ```javascript theme={"dark"} if (!signature) { return res.status(401).json({ error: 'Missing signature' }); } ``` ### 4. Log Verification Failures Monitor for repeated failures - could indicate an attack: ```javascript theme={"dark"} if (!verifySignature(payload, signature)) { logger.warn({ message: 'Webhook signature verification failed', ip: req.ip, webhookId: req.headers['x-webhook-id'] }); } ``` ### 5. Use HTTPS Only Signatures protect against tampering, but HTTPS protects against eavesdropping: * Always use HTTPS endpoints * Reject HTTP in production ## Debugging Signature Issues ### Step 1: Log Both Signatures ```javascript theme={"dark"} console.log('Received:', signature); console.log('Expected:', expected); ``` ### Step 2: Check Payload ```javascript theme={"dark"} console.log('Payload length:', payload.length); console.log('Payload:', payload.toString()); ``` ### Step 3: Verify Secret ```javascript theme={"dark"} console.log('Secret length:', WEBHOOK_SECRET.length); console.log('Secret first 4 chars:', WEBHOOK_SECRET.substring(0, 4)); ``` ### Step 4: Test with Known Values Use the KnoxCall test feature and compare: * Test payload vs what you receive * Expected signature vs what you compute ## Who can see a webhook's signing secret Revealing an existing webhook's signing secret is a reveal. In the dashboard this is a **Class B reveal**: only an **Owner** or an **Admin** can do it (an Editor cannot, even with **Reveal secrets** turned on; that setting covers secret values, certificates and credentialed workflow tests only), and every use asks for a fresh second factor (an authenticator code, a passkey or a security key; an emailed code does not count). Each use writes a `plaintext.reveal` entry to the audit log **before** anything is shown, and counts against the same per-person reveal budget as showing a secret's value (60 per 5 minutes). If the audit entry cannot be written, or the budget cannot be counted, the request is refused rather than served. On the Management API (`/v1`) the signing secret is returned only in the response that creates the webhook. A tenant policy that denies `webhook:reveal_secret` still refuses before your verification is spent. The secret returned when you **create** a webhook or **regenerate** its secret is different: it is minted for you in that request, nothing existing leaves, and it is shown once without a verification. ## Next Steps Test your signature verification Debug webhook issues Configure webhook settings Learn about webhooks *** * **Level**: intermediate * **Time**: 8 minutes `webhooks`, `security`, `signatures`, `hmac`, `verification` # Webhook Logs Source: https://docs.knoxcall.com/webhooks/webhook-logs Monitor webhook deliveries, troubleshoot failures, and correlate webhooks with API requests using the webhook logs. # Webhook Logs Every webhook execution is logged, allowing you to monitor deliveries, troubleshoot failures, and correlate webhooks with the original API requests. ## Viewing Webhook Logs ### All Webhook Logs 1. Click **Automation** in the sidebar, then select **Webhooks** 2. Click **Webhook Logs** tab 3. View all webhook executions across all webhooks ### Logs for a Specific Webhook 1. Click **Automation** in the sidebar, then select **Webhooks** 2. Click on a specific webhook 3. Select the **Logs** tab ## Log Entry Details Each log entry contains: | Field | Description | | - | - | | **Webhook Name** | The webhook that was triggered | | **Event Type** | The event that triggered the webhook (e.g., `request.success`) | | **Executed At** | Timestamp of the execution | | **Success** | Whether the delivery succeeded | | **Response Status** | HTTP status code from your endpoint | | **Response Time** | How long the request took (ms) | | **Retry Count** | Number of retry attempts | | **Request ID** | Links to the original API request | ## Log Entry Detail View Click on a log entry to see full details: The delivery payload (the event data, the headers and body KnoxCall sent, and the response body your endpoint returned) can carry proxied request bodies and audit details, so it is treated like a request log's captured payload: the detail opens with the payload **withheld** for everyone, and an Owner or Admin clicks **Reveal payload** to see it, with a fresh second factor each time and an audit entry written first (see [Who can see a delivery's payload](#who-can-see-a-deliverys-payload)). A log policy that withholds a request path's payload also withholds the delivery for that path, and an `audit.event` delivery also needs permission to read audit details. Everyone sees the delivery's status, timing, response code and any error. Values of the custom request headers you configure on a webhook are masked in the headers shown for each delivery, and wherever your endpoint echoes one back in its response body. A configured value that happens to appear in a response body is masked there too, so a header such as `Accept: application/json` also masks that text in response bodies. The URL, error message and event data are never altered. ### Request Sent ```json theme={"dark"} { "event": "request.success", "timestamp": "2025-01-15T10:30:45.123Z", "webhook_id": "wh_abc123", "webhook_name": "Slack Notifications", "data": { "route_id": "route_xyz789", "route_name": "payment-api", "request": { "method": "POST", "path": "/v1/charges" }, "response": { "status": 200, "latency_ms": 342 } } } ``` ### Headers Sent ```json theme={"dark"} { "Content-Type": "application/json", "X-Webhook-Signature": "sha256=abc123...", "X-Webhook-ID": "wh_abc123", "X-Webhook-Event": "request.success", "Authorization": "Bearer ***CONFIGURED***" } ``` ### Response Received ```json theme={"dark"} { "ok": true } ``` ### Error Message (if failed) ```text theme={"dark"} Connection timeout after 30000ms ``` ## Filtering Logs ### By Success/Failure ```text theme={"dark"} Success: ✓ Success Only ✗ Failures Only All ``` ### By Event Type ```text theme={"dark"} Event Type: request.success request.server_error request.completed (all) ``` ### By Webhook ```text theme={"dark"} Webhook: Slack Notifications Analytics Logger (all) ``` ### By Request ID Correlate with API logs: ```text theme={"dark"} Request ID: req_abc123def456 ``` ## Common Failure Scenarios ### Connection Timeout **Log shows:** ```text theme={"dark"} Error: Request timeout Response Time: 30000ms Success: false ``` **Causes:** * Your endpoint is too slow * Network connectivity issues * Firewall blocking requests **Solutions:** * Increase webhook timeout setting * Optimize your endpoint response time * Check firewall rules ### HTTP 4xx Response **Log shows:** ```text theme={"dark"} Response Status: 401 Unauthorized Success: false ``` **Causes:** * Incorrect authentication configuration * Token expired * Endpoint URL changed **Solutions:** * Verify auth credentials * Regenerate tokens if expired * Check endpoint URL ### HTTP 5xx Response **Log shows:** ```text theme={"dark"} Response Status: 500 Internal Server Error Success: false Retry Count: 3 ``` **Causes:** * Your endpoint has a bug * Your endpoint's dependencies are down * Rate limiting on your endpoint **Solutions:** * Check your endpoint logs * Verify endpoint dependencies * Implement proper error handling ### Connection Refused **Log shows:** ```text theme={"dark"} Error: connect ECONNREFUSED 127.0.0.1:3000 Success: false ``` **Causes:** * Endpoint server not running * Incorrect port * Firewall blocking **Solutions:** * Verify endpoint is running * Check port number * Review firewall rules ## Who can see a delivery's payload A delivery's detail is **metadata** for everyone: its status, timing, response code and any error. Its payload (the event data, the headers and body KnoxCall sent, and your receiver's response body) is withheld until you click **Reveal payload** (the API equivalent is `GET /admin/webhook-logs/{id}?include=payload`). In the dashboard this is a **Class B reveal**: only an **Owner** or an **Admin** can do it (an Editor cannot, even with **Reveal secrets** turned on; that setting covers secret values, certificates and credentialed workflow tests only), and every use asks for a fresh second factor (an authenticator code, a passkey or a security key; an emailed code does not count). Each use writes a `plaintext.reveal` entry to the audit log **before** anything is shown, and counts against the same per-person reveal budget as showing a secret's value (60 per 5 minutes). If the audit entry cannot be written, or the budget cannot be counted, the request is refused rather than served. The Management API's delivery logs (`/v1/webhooks/{id}/logs`) return metadata only. A log policy that withholds a request path's payload, or the audit-details permission an `audit.event` delivery also needs, still refuses before your verification is spent. The delivery lists stay metadata-only. ## Correlating with API Logs Each webhook log includes a `request_id` that links to the original API request: 1. Find the webhook log entry 2. Copy the **Request ID** 3. Navigate to **API Logs** 4. Search by Request ID This lets you see: * The original request that triggered the webhook * Full request/response details * Client information * Timing breakdown ## Webhook Statistics On the webhook detail page, view aggregate statistics: | Metric | Description | | - | - | | **Trigger Count** | Total times triggered | | **Success Count** | Successful deliveries | | **Failure Count** | Failed deliveries | | **Last Triggered** | Most recent trigger time | | **Last Success** | Most recent successful delivery | | **Last Failure** | Most recent failed delivery | ## Log Retention Webhook logs are retained based on your plan: | Plan | Retention | | - | - | | Starter | 7 days | | Pro | 30 days | | Enterprise | 90 days | ## Next Steps Test your webhook configuration Correlate with API requests Learn about webhooks Create your first webhook *** * **Level**: beginner * **Time**: 5 minutes `webhooks`, `logs`, `monitoring`, `debugging`, `troubleshooting` # Webhooks Overview Source: https://docs.knoxcall.com/webhooks/webhooks-overview Send real-time notifications to external services when API requests are made through your routes. React to successes, failures, and other events. # Webhooks Overview Webhooks let you send real-time notifications to external services whenever requests pass through your KnoxCall routes. Use them to trigger workflows, update databases, send alerts, or integrate with third-party services. ## What Are Webhooks? **Webhooks** are outbound HTTP requests that KnoxCall sends to your specified endpoint when certain events occur. **How it works:** ```text theme={"dark"} Client Request → KnoxCall Route → Backend API ↓ Webhook Triggered ↓ Your Webhook Endpoint (Slack, Zapier, custom server, etc.) ``` **Example use case:** ```text theme={"dark"} Route: payment-api Event: request.server_error (5xx) Webhook URL: https://hooks.slack.com/services/... Result: Slack notification when payment API fails ``` ## Event Types KnoxCall supports 8 event types that follow a Stripe-like naming convention: | Event Type | Triggers When | Example Use Case | | - | - | - | | `request.received` | Request arrives at KnoxCall | Logging, real-time dashboards | | `request.success` | 2xx response received | Analytics, success tracking | | `request.redirect` | 3xx response received | Redirect monitoring | | `request.client_error` | 4xx response received | Invalid request tracking | | `request.server_error` | 5xx response received | Error alerting | | `request.timeout` | Request times out | Timeout monitoring | | `request.error` | Any non-2xx response | General error handling | | `request.completed` | Any response completed | Comprehensive logging | **Combine event types**: You can subscribe a single webhook to multiple event types. For example, subscribe to both `request.success` and `request.server_error` to track all outcomes. ## Webhook Payload When an event occurs, KnoxCall sends a JSON payload to your endpoint: ```json theme={"dark"} { "event": "request.success", "timestamp": "2025-01-15T10:30:45.123Z", "webhook_id": "wh_abc123", "webhook_name": "Slack Notifications", "data": { "route_id": "route_xyz789", "route_name": "payment-api", "environment": "production", "request": { "method": "POST", "path": "/v1/charges", "ip": "52.123.45.67", "headers": { "content-type": "application/json" }, "body": { "amount": 1000, "currency": "usd" } }, "response": { "status": 200, "headers": { "content-type": "application/json" }, "body": { "id": "ch_abc123", "status": "succeeded" }, "latency_ms": 342 } } } ``` ### Payload Configuration Control what's included in your webhook payload: | Option | Default | Description | | - | - | - | | Include Request Body | Yes | Include the original request body | | Include Response Body | Yes | Include the backend response body | | Include Headers | No | Include request/response headers | | Include Status Code | Yes | Include HTTP status code | **Sensitive data**: When including headers, sensitive headers like `Authorization`, `X-API-Key`, and `Cookie` are automatically redacted for security. ## Authentication Methods Secure your webhook endpoints with multiple authentication options: ### Bearer Token ```text theme={"dark"} Authorization: Bearer your-secret-token ``` ### Basic Auth ```text theme={"dark"} Authorization: Basic base64(username:password) ``` ### Custom Header ```text theme={"dark"} X-Custom-Auth: your-secret-value ``` ### HMAC Signature Every webhook includes an HMAC-SHA256 signature for verification: ```text theme={"dark"} X-Webhook-Signature: sha256=abc123... ``` Verify the signature on your server: ```javascript theme={"dark"} const crypto = require('crypto'); function verifyWebhook(payload, signature, secret) { const expectedSignature = crypto .createHmac('sha256', secret) .update(JSON.stringify(payload)) .digest('hex'); return `sha256=${expectedSignature}` === signature; } ``` ## Headers Sent KnoxCall includes these headers with every webhook request: | Header | Description | | - | - | | `Content-Type` | Always `application/json` | | `X-Webhook-Signature` | HMAC-SHA256 signature for verification | | `X-Webhook-ID` | Unique identifier for the webhook configuration | | `X-Webhook-Event` | Event type (e.g., `request.success`) | | `User-Agent` | `KnoxCall-Webhook/1.0` | Plus any custom headers you configure. ## Route Filtering Limit webhooks to specific routes: **All routes (default):** ```text theme={"dark"} Route Filter: (empty) → Triggers for all routes in your tenant ``` **Specific routes:** ```text theme={"dark"} Route Filter: ["route_abc123", "route_def456"] → Only triggers for these two routes ``` **Use case:** Create separate webhooks for different integrations: * `payment-webhook`: Only payment-related routes → Slack #payments * `auth-webhook`: Only authentication routes → Security monitoring tool ## Retry Logic When a webhook delivery fails, KnoxCall can automatically retry: **Configuration:** * **Retry on Failure**: Enable/disable retries * **Max Retries**: 0-5 attempts (default: 3) **Backoff schedule:** ```text theme={"dark"} Attempt 1: Immediate Attempt 2: 1 second delay Attempt 3: 2 seconds delay Attempt 4: 4 seconds delay Attempt 5: 8 seconds delay ``` **Success criteria:** HTTP 2xx response Webhooks are dispatched **asynchronously** (fire-and-forget). They don't block or slow down the proxied request. ## Webhook Logs Track all webhook executions in the webhook logs: **What's logged:** * Execution timestamp * Event type * HTTP method and URL * Sent headers and body * Response status and body * Response time (ms) * Success/failure status * Error messages (if failed) * Retry count * Linked request ID **Filter logs by:** * Webhook ID * Success/failure status * Event type * Date range * Request ID (to correlate with API logs) ## Common Use Cases ### 1. Error Alerting (Slack) ```text theme={"dark"} Event: request.server_error URL: https://hooks.slack.com/services/... Include: Status code, route name, error message ``` ### 2. Analytics/Logging ```text theme={"dark"} Event: request.completed URL: https://your-analytics.com/ingest Include: All data (full request/response) ``` ### 3. Workflow Automation (Zapier) ```text theme={"dark"} Event: request.success URL: https://hooks.zapier.com/hooks/catch/... Include: Request body, response body ``` ### 4. Security Monitoring ```text theme={"dark"} Event: request.client_error (401, 403) URL: https://your-security-tool.com/api Include: Request headers, IP address ``` ### 5. Real-time Dashboards ```text theme={"dark"} Event: request.received URL: wss://your-dashboard.com/ws Include: Route name, timestamp ``` ## Performance Considerations * **Non-blocking**: Webhooks are dispatched asynchronously * **Parallel dispatch**: Multiple webhooks for the same event run in parallel * **Timeout**: Configurable timeout (1-60 seconds, default: 30) * **No impact on latency**: Webhook failures don't affect the proxied request ## Next Steps Step-by-step webhook setup guide How to test your webhook configuration Viewing and debugging webhook executions Correlate webhooks with API requests *** * **Level**: intermediate * **Time**: 8 minutes `webhooks`, `notifications`, `events`, `integrations`, `slack`, `zapier` # AI in Workflows Source: https://docs.knoxcall.com/workflows/ai Add an AI step to classify, extract, summarize, generate or translate with structured output — and let the AI copilot draft a whole workflow from a prompt or explain a failed run. # AI in Workflows KnoxCall brings AI into workflows two ways: an **AI step** you drop into a graph like any other node, and an **AI copilot** that drafts workflows from a plain-language prompt and explains failed runs. ## The AI Step The **AI** node runs an Anthropic model with **structured output**: each operation declares the shape it returns, and you get typed data back with no prompt-parsing glue. Five operations: | Operation | What it does | Key config | Output | | - | - | - | - | | **Classify** | Picks one label from a list | `categories: [...]` | `category`, `confidence`, `reasoning` | | **Extract** | Pulls named fields out of text | `fields: [{ name, description, type? }]` | `fields` (an object) | | **Summarize** | Condenses text to about a target length | `maxWords` (default 80) | `summary` | | **Generate** | Free-form writing | — | `text` | | **Translate** | Translates into another language | `targetLanguage` (e.g. `"French"`, `"pt-BR"`) | `text`, `sourceLanguage` | Every operation takes an `input` (the text to work on, or the prompt for Generate) and an optional `instruction` for extra guidance. Both support `{{ ... }}` variables, and so does `targetLanguage`. **Example — classify an inbound message:** ```json theme={"dark"} { "type": "ai", "data": { "type": "ai", "config": { "operation": "classify", "input": "{{trigger.data.message}}", "categories": ["billing", "technical", "sales"] } } } ``` Reference the result downstream by the node's id, for example `{{ai_1.category}}`. **Example — translate a reply into the customer's language:** ```json theme={"dark"} { "operation": "translate", "input": "{{draft_1.text}}", "targetLanguage": "{{trigger.data.customer.locale}}" } ``` ### Output length **Max Output Tokens** caps how much the model may write, on every operation. The default is 1,024 and the maximum is 16,000. The cap bounds what a single step can spend and how long it holds the run, so raise it only for steps that genuinely produce long output, such as a long extraction or a full-length draft. A value set through a `{{variable}}` is checked when the run happens: anything that isn't a number falls back to 1,024, and anything out of range is clamped. ### Model & Credentials AI steps run on **your own Anthropic key**. Add it under **Settings → Integrations → Anthropic**. There is no fallback to a KnoxCall key: without yours, the step fails with a message telling you to add one. The model is configured centrally, not per node; the default is `claude-sonnet-5`. Calls go through the KnoxCall AI Gateway, so an AI step gets the same budgets, firewall and usage attribution as the rest of your AI traffic. ### Metering & Limits * AI nodes count as **weighted operations (×3)** against your monthly operations quota. * AI-token usage is metered against a monthly **AI-token budget** per plan tier. * When either limit is reached, an AI step is blocked before it spends anything, rather than overspending. ## The AI Copilot Open the **AI** panel in the builder toolbar to draft a workflow from a description. ### Draft a workflow from a prompt Describe what you want — for example: > "When a GitHub issue is opened, classify it as bug or feature, and post a Slack message to the right channel." The copilot returns a coherent, **auto-laid-out** graph (trigger → nodes → edges) that lands **unsaved** on your canvas. It goes through the same publish gate as a hand-built workflow: structural validation is enforced, and any per-node configuration gaps come back as **warnings** so you know exactly what to fill in (connections, secrets, endpoints) before publishing. ### Explain a run On any finished execution, click **Explain** in the run log. The copilot reads the run's redacted step history and returns a plain-language explanation of what happened plus a **suggested fix** — for example, diagnosing an unreachable URL and pointing at the exact node to correct. ### Metering Copilot calls are rate-limited and draw from the same AI-token budget as AI steps (each call reports its own token cost). ## Tips * Start Extract/Classify with a few clear labels or fields; add more only if results are ambiguous. * Use the copilot to scaffold, then refine node config by hand — it's a starting point, not a black box. * Keep an Anthropic key in Integrations if you want AI usage billed to your own account. ## Next Steps Add an AI step to a graph Start from a pre-built workflow # Using the Workflows API Source: https://docs.knoxcall.com/workflows/api Manage and run workflows over the public /v1 API: CRUD, idempotent execution, execution history, cancellation, and completion webhooks. # Using the Workflows API Workflows are a first-class `/v1` resource. Everything you can do in the builder — create, update, run, inspect, and cancel — is available over the API, using KnoxCall's standard response envelope, pagination, idempotency, and scopes. ## Authentication & Scopes Authenticate with a short-lived OAuth 2.1 access token (recommended — see [Authentication](/api-reference/authentication); legacy `tk_…` API keys also work as Bearer tokens). The credential's mode (Live/Test) scopes every response. The curl examples below assume a minted token: ```bash theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) ``` Access is governed by workflow scopes: | Action | Scope | | - | - | | List / read | `workflow:read` (and list) | | Create / update / delete | `workflow` write scopes | | Execute / cancel | `workflow:execute` | Read-only credentials can list and read; invoke credentials can additionally execute; editor/member credentials can manage. A credential without the required scope gets `403`. ## Response Envelope Every response is wrapped: ```json theme={"dark"} { "data": { }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` List endpoints paginate with `page` and `per_page` query parameters and return pagination in `meta`: ```json theme={"dark"} { "data": [ /* ... */ ], "meta": { "total": 42, "page": 1, "per_page": 20, "total_pages": 3, "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` ## Endpoints | Method & path | Description | | - | - | | `GET /v1/workflows` | List workflows (paginated) | | `POST /v1/workflows` | Create a workflow | | `GET /v1/workflows/{id}` | Get a workflow | | `PATCH /v1/workflows/{id}` | Update a workflow (a new definition bumps the version) | | `DELETE /v1/workflows/{id}` | Delete a workflow and its executions | | `POST /v1/workflows/{id}/execute` | Queue a run (idempotent) | | `GET /v1/workflows/{id}/executions` | List a workflow's runs (paginated) | | `GET /v1/workflows/executions/{executionId}` | Get one run with step details | | `POST /v1/workflows/executions/{executionId}/cancel` | Request cancellation of a run | ### Create a workflow The `definition` is a graph with a `nodes` array containing exactly one trigger node and an `edges` array. It's validated (structure + reachable trigger) on write. ```bash theme={"dark"} curl -X POST https://api.knoxcall.com/v1/workflows \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "New customer onboarding", "enabled": true, "definition": { "nodes": [ { "id": "trigger-node", "type": "trigger", "position": { "x": 0, "y": 0 }, "data": { "type": "trigger", "config": { "triggerType": "manual" } } } ], "edges": [] } }' ``` Returns `201` with the created workflow (`version: 1`). An invalid graph returns `422`. ### Execute a workflow (idempotent) ```bash theme={"dark"} curl -X POST https://api.knoxcall.com/v1/workflows/{id}/execute \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -H "X-Idempotency-Key: onboarding-42" \ -d '{"input": {"email": "user@example.com"}}' ``` The run is queued asynchronously and the call returns `201` immediately: ```json theme={"dark"} { "data": { "id": "execution-uuid", "workflow_id": "{id}", "status": "queued" }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` Send the **same `X-Idempotency-Key`** again and you get the **same** execution back, with an `X-Idempotent-Replay: true` response header — a safe retry never starts a second run. ### Inspect executions List a workflow's runs (the polling source for automation platforms): ```bash theme={"dark"} curl "https://api.knoxcall.com/v1/workflows/{id}/executions?page=1&per_page=20" \ -H "Authorization: Bearer $TOKEN" ``` Get a single run with composed step details: ```bash theme={"dark"} curl https://api.knoxcall.com/v1/workflows/executions/{executionId} \ -H "Authorization: Bearer $TOKEN" ``` An execution's `status` is one of `queued`, `running`, `waiting`, `paused`, `completed`, `failed`, `cancelled`, or `timed_out`. Step inputs/outputs are redacted — secret values appear as `[REDACTED]`. ### Cancel a run ```bash theme={"dark"} curl -X POST https://api.knoxcall.com/v1/workflows/executions/{executionId}/cancel \ -H "Authorization: Bearer $TOKEN" ``` Cancellation aborts in-flight work (including outbound HTTP), not just a flag. ## Webhook Events Rather than polling, subscribe an outbound webhook to run-completion events: | Event | Fires when | | - | - | | `workflow.execution.completed` | A run finishes successfully | | `workflow.execution.failed` | A run ends in failure | These give automation platforms (Zapier, Make, n8n) an **instant** trigger on run completion. Configure them like any other [webhook](/webhooks/webhooks-overview); payloads are signed and delivered through the same hardened egress. ## SDKs, Postman & OpenAPI The workflows resource is available in the KnoxCall SDKs (`workflows.list / get / create / update / delete / execute / listExecutions / getExecution / cancelExecution`), in the Postman collection (**Workflows** folder), and in the OpenAPI spec. The list methods (`list` / `listExecutions`) return the `{ data, meta }` envelope; single-object methods (`get` / `create` / `execute` / …) return the unwrapped resource. The idempotency key is supported on `create` / `execute`. ## Next Steps Webhook and app-event triggers Get notified when runs finish # Connections Source: https://docs.knoxcall.com/workflows/connections Connect third-party apps like Slack, GitHub, and Stripe once, then use their operations as workflow nodes — credentials stay encrypted and are injected at runtime. # Connections A **connection** is a saved, encrypted credential for a third-party app. Connect an app once, then drop its operations into any workflow as action nodes — KnoxCall injects the credentials at runtime and calls the provider through its hardened, SSRF-pinned transport. Your secrets are never stored in a workflow definition and never appear in logs. ## Catalog These apps ship in the connector catalog: | App | Auth | Example operations | | - | - | - | | **SendGrid** | API key | Send Email (plain, HTML or dynamic template), Add / Remove Contact, Get Contact by Email | | **Stripe** | API key | Create / Retrieve / List Customer, Create Payment Link, Retrieve Charge, Create Refund, Stripe-event trigger | | **Twilio** | API key | Send SMS / WhatsApp, Make Call (TwiML URL), Get / List Messages, Look Up Phone Number, incoming-message polling | | **Slack** | OAuth | Send / Update / Delete Message, Add Reaction, Get User (id or email), new-message polling | | **GitHub** | OAuth | Create / Get / List Issue, Add Issue Comment, Add Labels, Create Pull Request; issue-opened, PR-opened and push triggers | | **Notion** | API key | Create Page, Create Database Page, Query Database, Retrieve Page, Update Page Properties, Append Paragraphs, database-row-added polling | | **Linear** | API key | Create / Get / Update / List Issue, Create Comment, Create Project, new-issue polling | | **Discord** | Bot token | Send / Edit / Delete Message, Add Reaction, Create Thread, Get Server Member, Add Role to Member, new-message polling | | **Airtable** | API key (PAT) | Create / Get / Update / Delete / List / Upsert Record, new-record polling | | **Telegram** | Bot token | Send Message / Photo / Document, Edit Message Text, Delete Message, Answer Callback Query, new-message polling | | **Jira** | API token (site + email) | Create / Get / Update / Transition Issue, Add Comment, Search Issues (JQL); project, issue-type and transition pickers; issue-created polling | | **GitLab** | Access token (PAT) | Create / Get / List Issue, Create Merge Request, Add Note, Get Pipeline; project and branch pickers; issue-opened, MR-opened and push triggers (gitlab.com only) | | **Shopify** | Admin API token (custom app) | Get / List Order, Create Customer, Get Product, Set Inventory Level; location and product pickers; order-created polling | | **HubSpot** | API key (private-app token) | Create / Get / Update / Search Contact, Create Deal (pipeline + stage pickers), Create Note, contact-created polling | | **OpenAI** | API key | Create Chat Completion, Create Embedding, Moderate Text, List Models — your own OpenAI account | | **Anthropic** | API key | Create Message, Count Tokens, List Models — your own Anthropic account | **The OpenAI and Anthropic connectors are not the AI step.** The built-in **AI** node runs on the KnoxCall AI Gateway: KnoxCall selects and pins the model, the call is metered against your AI spend, and gateway policy and caching apply. These two connectors call **your own provider account** on a key you hold and are billed by the provider — for a specific model, a parameter the AI step does not expose, or an endpoint it does not proxy (embeddings, moderation, token counting). The model is always chosen by you: there is no default, because a pinned default would silently follow a model into retirement. Neither is a fallback for the other. Each operation appears in the builder's **Apps** palette category and, once added, references a connection you select in the node inspector. ## Creating a Connection 1. Go to **Workflows → Connections** (or **Connections** in the sidebar). 2. Click **New Connection** and pick an app. 3. Provide credentials: * **API-key / token apps** (SendGrid, Stripe, Twilio, Notion, Linear, Discord, Airtable, Telegram, Jira, GitLab, Shopify, HubSpot, OpenAI, Anthropic): paste the key. KnoxCall **verifies it against the real provider** before saving, so a bad key fails fast. * **OAuth apps** (Slack, GitHub): click **Connect** to open the provider's consent screen in a popup. On approval the window closes and the connection goes **active**. 4. The connection is saved encrypted (envelope encryption, per tenant). Only masked metadata is ever shown back — the raw secret is write-only. Connections are scoped by mode (**Live** / **Test**) just like the rest of KnoxCall. A Test-mode workflow can only use Test-mode connections. ## Using a Connection in a Workflow 1. In the builder, open the **Apps** category in the node palette and add an operation (e.g. *Slack → Send Message*). 2. In the inspector, choose the **Connection** to use. 3. Fill in the operation's fields — they support `{{ ... }}` variables like any other node. At runtime the connector calls the provider over an exact-host-allowlisted HTTPS transport with a per-app rate limit. If an OAuth token has expired, the connector refreshes it once and retries automatically. ## OAuth: Managed vs. Bring-Your-Own Client For OAuth apps, KnoxCall needs a registered OAuth client with the provider: * **Cloud (knoxcall.com):** KnoxCall manages a platform OAuth client for each supported app — just click **Connect**. * **Self-hosted / BYO:** register your own OAuth app with the provider and enter its client ID/secret under the app's settings. Your client takes precedence over any platform default. This is required on self-hosted installs and whenever a "needs BYO client" prompt appears. The OAuth redirect URL is a single per-environment endpoint (`https://knoxcall.com/oauth/apps/callback` and its staging twin); the specific app and connection are carried in a signed, encrypted state parameter, so you don't register a URL per app. ## Connection Health * **Reauthorize** — if a provider revokes access or a token can't refresh, the connection moves to **error**; use **Reauthorize** to fix it. * **Automatic refresh** — refreshable OAuth tokens are renewed ahead of expiry by a background lease. * **Health sweep** — connections not checked in over 24 hours are re-verified in the background, so silently-revoked credentials surface as errors before a workflow run hits them. ## Security * Credentials are **envelope-encrypted per tenant** and write-only; the UI shows only masked metadata. * All connector traffic goes through the same SSRF-pinned egress as the rest of KnoxCall, restricted to each provider's exact hostnames. * Secret values are redacted everywhere they could surface — step logs, the run overlay, and execution details. ## Next Steps Fire workflows from app events Build a workflow that uses a connection # Creating Workflows Source: https://docs.knoxcall.com/workflows/creating-workflows Step-by-step guide to building your first workflow: add a trigger, chain nodes, use variables and secrets, then run and monitor it. # Creating Workflows This guide walks you through building your first workflow — from trigger to execution and monitoring. ## Prerequisites You'll need a KnoxCall account and a basic understanding of APIs (HTTP requests, JSON). Workflow executions consume a metered monthly **operations** quota (each run counts nodes against your tenant limit; AI nodes count ×3). When the limit is reached, an execution is recorded as failed without running. Workflow access and connection/AI limits also depend on your plan. ## Step 1: Create a New Workflow 1. Navigate to **Workflows** in the sidebar. 2. Click **+ Create Workflow**. 3. Enter a name, optional description, and optional environment. 4. Click **Create**. You'll see the builder with an empty canvas and a trigger node. Prefer a head start? Click **Templates** and pick a starter — it lands as a disabled draft. See [Templates](/workflows/templates). ## Step 2: Configure the Trigger Click the trigger node to configure it. ### Manual Trigger (recommended for your first workflow) ```yaml theme={"dark"} Trigger Type: Manual ``` Run the workflow on demand from the UI or API. ### Webhook Trigger ```yaml theme={"dark"} Trigger Type: Webhook Method: POST ``` Publishing the workflow gives you a signed URL of the form `https://api.knoxcall.com/hooks/wf/{token}` to `POST` to. ### Route Event Trigger ```yaml theme={"dark"} Trigger Type: Route Event # underlying triggerType: proxy_event Event Types: # e.g. request.completed Route Filter: (optional) ``` Fires when requests pass through your routes. Valid event types are `request.received`, `request.success`, `request.error`, `request.client_error`, `request.server_error`, `request.timeout`, and `request.completed`. For schedule, inbound-webhook, and app-based triggers, see [Triggers](/workflows/triggers). ## Step 3: Add an HTTP Request Node 1. Click the **+** on the trigger node's output. 2. Select **HTTP Request**. 3. Configure it: ```yaml theme={"dark"} Name: Fetch User Data Method: GET URL: https://jsonplaceholder.typicode.com/users/1 Headers: Content-Type: application/json ``` This calls a free test API that returns a user record. **Calling one of your own routes?** Switch the node's target from **URL** to **Route** and pick the route. KnoxCall then calls it with a short-lived workflow credential and identifies itself as the platform-managed **KnoxCall Workflows** client — so if that route's environment only accepts authorized clients, assign "KnoxCall Workflows" to the environment (Route → Clients tab) and the step is allowed through. Typing a `*.knoxcall.com` URL into a URL-mode node is refused: KnoxCall never sends workflow traffic back to itself. **One-time setup:** Route mode uses a workflow data-plane credential that a tenant owner or admin provisions once per mode, with `POST /admin/workflows/internal-key` (Live and Test are independent, so provision each mode you run in). Until it exists, a Route-mode step fails with a message saying exactly that, rather than calling anything. ## Step 4: Add a Code Block Node The Code Block runs real JavaScript in a secure sandbox. 1. Click the **+** on the HTTP Request node and select **Code Block**. 2. Name it "Transform Data". 3. Enter the code: ```javascript theme={"dark"} // `input` is the upstream node's output (the HTTP Request). // For an HTTP Request node the response body is under `input.body`. const user = input.body; return { id: user.id, fullName: user.name, email: user.email, company: user.company.name }; ``` A Code Block receives the upstream node's output directly as the global `input` (for a single incoming edge). For fan-in (multiple incoming edges), `input` is an array of `{ nodeId, output }` entries keyed by node **id**. The sandbox has no network, filesystem, or timer access. **`console.log()` works.** Anything a script prints appears as **Console output** above the step's result — in the node's **Test** tab while you're writing it, and in the **Run Log** for any run afterwards. Long output is capped, and the panel says when lines were dropped. A value the run resolved from `{{secrets.NAME}}` is masked on the way to storage and shows as `[REDACTED]`. That masking works by matching the resolved secret values, so it does **not** cover a credential your script builds or fetches itself — those print verbatim and are stored with the step. Treat step output as readable by anyone on your team who can open the run. ## Step 5: Add a Condition Node 1. Click the **+** on the Code Block node and select **Condition**. 2. Configure: ```yaml theme={"dark"} Name: Check User ID Logic: AND Conditions: - Type: equals Path: $.id Value: 1 ``` Condition nodes evaluate a structured list of conditions (each a `type`, a JSONPath `path`, and a comparison `value`) combined with `logic` (`AND`/`OR`). The node activates its **True** or **False** outgoing edge; the other branch is recorded as skipped and greyed on the canvas. ## Step 6: Add Nodes to Each Branch ### True path Add a Code Block on the Condition's **True** output, name it "Success Response": ```javascript theme={"dark"} // The Condition node passes its evaluated data through as `input.input`. return { status: 'success', message: 'User verified!', data: input.input }; ``` ### False path Add a Code Block on the Condition's **False** output, name it "Error Response": ```javascript theme={"dark"} return { status: 'error', message: 'User verification failed', received_id: input.input.id }; ``` ## Step 7: Save & Publish 1. Autosave keeps your **draft** current as you edit — the draft does not affect live runs. 2. Click **Publish** to validate the graph, snapshot **version 1**, and make it live. Your workflow should look like this: ```text theme={"dark"} [Trigger] ↓ [Fetch User Data] (HTTP Request) ↓ [Transform Data] (Code Block) ↓ [Check User ID] (Condition) ↓ True ↓ False [Success] [Error] ``` ## Step 8: Test Your Workflow ### Test a single node Open a node's **Test** tab in the inspector to dry-run just that node with real secrets and see its (redacted) output — without running the whole graph. ### Run the whole workflow 1. Click **Run**. 2. Optionally add trigger input: ```json theme={"dark"} { "test": true } ``` 3. Watch each node highlight as it executes: green on success, red on error, with per-node timing. Open the **Run Log** to inspect each step's input/output. ## Step 9: Enable the Workflow Toggle **Enabled** to ON. For webhook and app triggers, publishing registers the delivery endpoints described in [Triggers](/workflows/triggers). ## Using Variables Reference data anywhere with `{{ ... }}` expressions across these namespaces: ```text theme={"dark"} {{fetch_user_data.body.id}} # any node's output, by node id {{trigger.data.user_id}} # the trigger's output (also {{trigger-node.*}}) {{input.foo}} # the current node's direct input {{loop.item}} {{loop.index}} # inside a Loop body {{vars.counter}} # a value set by a Set Variable node {{secrets.API_KEY}} # a stored secret, injected at runtime {{env.REGION}} # an environment variable for the active environment ``` Unknown names are left literal. For an HTTP Request node the response fields are `status`, `statusText`, `headers`, `body`, `url`, `method`, and `success`. ### Secrets Inject stored secrets into any templated field — headers, URLs, bodies, or an app connection — with `{{secrets.NAME}}`: ```yaml theme={"dark"} Name: Create Record Method: POST URL: https://api.example.com/records Headers: Authorization: "Bearer {{secrets.PARTNER_API_KEY}}" Body: name: "{{transform_data.fullName}}" ``` The value is resolved from your tenant's secret store (scoped to the active environment) at runtime and **never** appears in step logs or the run overlay — it shows as `[REDACTED]`. For provider integrations, prefer a saved [Connection](/workflows/connections) over a raw HTTP Request. ## Arranging the Canvas A workflow you can read is a workflow you can debug. * **Auto Layout** (toolbar) re-lays the whole graph left-to-right: the trigger on the left, each step one column right of the last step that feeds it, and siblings stacked without overlapping. Loop bodies and branches keep their shape, and a node nothing reaches still gets its own space rather than being piled on top of the graph. * **Align and distribute**: select two or more nodes (drag a box, or Ctrl/Cmd-click), right-click one of them, and choose **Align** left / center / right / top / middle / bottom. With three or more selected you also get **Distribute horizontally** / **vertically**, which spreads them evenly between the two outermost nodes and leaves those two where they are. Both are one undo step — **Ctrl+Z** puts every node back where it was. ## More Nodes ### Loop over an array ```yaml theme={"dark"} Name: Process Items Loop Type: array Max Iterations: 100 Array Config: Array Path: $.items Batch Size: 1 ``` Inside the body, reference the current element with `{{loop.item}}` and its position with `{{loop.index}}`. The body is a full subgraph — branches and nested loops are allowed. ### Delay ```yaml theme={"dark"} Name: Wait Before Retry Duration: 5 Unit: seconds # seconds, minutes, hours, or days ``` Long delays are durable — the run suspends and resumes on time, even across a worker restart. Anything over 30 seconds parks the run rather than holding a worker, so a three-day wait costs nothing while it waits. Two places cannot park a run: **Run step test** (there is no run to suspend) and a Loop set to process batches in parallel. A long delay there waits 30 seconds instead, and says so on the step output as `delayClamped` with the duration you asked for — it is never silently shortened. Set the loop's batch size to 1 if its body needs a real wait. ### Error Handler Place an Error Handler downstream of a node that might fail and choose a strategy (`continue`, `retry`, or `fallback`); retries are durable with backoff. ```javascript theme={"dark"} // In a Code Block after the Error Handler. The caught error is on the input. return { error_logged: true, original_error: input.originalError }; ``` ### AI step Add an **AI** node to classify, extract, summarize, or generate with structured output. See [AI in Workflows](/workflows/ai). ## Sticky Notes Leave notes on the canvas for whoever opens the workflow next — who owns it, why a branch exists, what a vendor's API quirk is. * **Add one:** right-click an empty spot on the canvas and choose **Add sticky note**. * **Write in it:** double-click the note. Markdown works — headings, **bold**, lists, tables, links. **Ctrl+Enter** (**Cmd+Enter** on macOS) or clicking away saves; **Escape** discards the edit. * **Move and resize:** drag the note; select it to show resize handles. * **Remove one:** select it and press **Delete**, or right-click it and choose **Delete note**. **Undo** brings it back. Notes are annotations, not steps: nothing connects to them and they never run. They're saved with the workflow and included when you publish, and anyone in your organization who can open the workflow can read them. Raw HTML and images in a note are not rendered, and links open in a new tab. Never paste a credential into a note — use a [Secret](#secrets). Saving refuses a workflow whose note contains something that looks like one (an API key with a known prefix, a private key), just as it would in a step's configuration. A workflow can hold up to 100 notes of up to 4,000 characters each. ## Versioning & Rollback The workflow you edit is a draft; **Publish** snapshots a new version and makes it live. Open the **Versions** tab to see every version, and click **Restore** on any prior version — it loads into your draft so you can review before publishing. ## Troubleshooting ### A variable renders literally (e.g. `{{foo.bar}}`) The referenced node hasn't run yet on this path, or the name/path is wrong. Check the node id and the dot-path against the node's actual output in a sample run. Unknown names are intentionally left as-is rather than becoming `undefined`. ### "HTTP Request failed" / `ECONNREFUSED` The target is unreachable. Verify the URL, confirm auth, and ensure the API is reachable from the internet. ### A branch never runs The upstream Condition/Router isn't activating that edge. Confirm the condition `type` matches the value's type and the JSONPath `path` resolves as expected; skipped branches are greyed on the canvas. ## Best Practices 1. **Name nodes descriptively.** 2. **Use connections and `{{secrets.*}}`** instead of hard-coding credentials. 3. **Add error handlers** on external calls. 4. **Test per-node** before running the whole graph. 5. **Keep workflows focused** — one purpose each. ## Next Steps Concepts, node catalog, and execution model Connect apps like Slack and Stripe Schedule, webhook, and app triggers Manage and run workflows via /v1 *** * **Level**: beginner * **Time**: 12 minutes `workflows`, `tutorial`, `getting-started`, `automation` # Database Source: https://docs.knoxcall.com/workflows/database Query your own PostgreSQL or MySQL from a workflow — parameters bound by the driver, read-only by default, over TLS, with the credentials held in a Secret. # Database The **Database** step runs one SQL statement against **your own** PostgreSQL or MySQL and hands the rows to the rest of the workflow. It is how a workflow enriches a webhook payload from your users table, looks up an order before calling an API, or writes a result back where your application can see it. It is not a database KnoxCall hosts. The server is yours; KnoxCall connects to it, runs the statement you wrote, and forgets the connection. ## Setting one up Create a Secret whose value is a connection URI: ```text theme={"dark"} postgres://appuser:the-password@db.example.com:5432/appdb ``` `postgres://`, `postgresql://`, `mysql://` and `mariadb://` are all understood — a JDBC-style `jdbc:postgresql://…` prefix is not; strip it. The Secret is envelope-encrypted like every other Secret and is resolved only while the step runs — it is never written into the workflow. Choose **PostgreSQL** or **MySQL** to match the Secret, then select the Secret in **Connection Secret**. ```sql theme={"dark"} SELECT id, email, plan FROM users WHERE email = $1 AND active = $2 ``` ```json theme={"dark"} ["{{trigger.body.email}}", true] ``` ## Values go in Parameters, never in the SQL The SQL box is **not** templated. `{{variables}}` in it are refused when you save, and refused again if the step somehow runs. That is deliberate, and it is the single most important thing on this page. A value pasted into a statement stops being a value — `' OR '1'='1` in the middle of a `WHERE` clause is *syntax*, and it is how databases get emptied. A value in **Parameters** is sent to your database separately from the statement and can never be parsed as SQL, whatever it contains. Write the placeholders your database uses: | Database | Placeholder | Example | | - | - | - | | PostgreSQL | `$1`, `$2`, `$3` … | `WHERE id = $1 AND created_at > $2` | | MySQL / MariaDB | `?` | `WHERE id = ? AND created_at > ?` | Parameters is a JSON array, in placeholder order, and **is** templated: ```json theme={"dark"} ["{{trigger.body.id}}", "{{steps.lookup.output.since}}", 25] ``` Values keep their type: a number stays a number, `true` stays a boolean, an object becomes JSON. Never put a `{{secrets.NAME}}` in the SQL or in Parameters. It is refused when you save. A credential in a statement is written to your own database server's query log, and a credential in a parameter comes back inside any error the database raises about it — onto a step row every member of your workspace can read. The connection Secret is the only credential this step needs. A **pasted connection string** — `postgres://user:password@host/db` typed into the SQL box, into Parameters, or into the Connection Secret field instead of picking a Secret — is refused the same way, in every step of every kind, not just this one. That includes the JDBC spelling (`jdbc:postgresql://user:password@host/db`, `jdbc:mysql://…`) and any other scheme prefix, wherever it appears in the value. A workflow definition is readable by everyone in your workspace, so a password in one is a password disclosed. If a step's configuration is too large for KnoxCall to check, saving is refused rather than allowed unchecked. ## One statement per step A Database step runs exactly one statement. `SELECT 1; DROP TABLE users` is refused when you save, and your database refuses it again at run time: KnoxCall always uses the prepared-statement protocol, which cannot carry two commands. If you need several statements, use several steps — or put them in a function or a view and call that. ## Read-only by default **Read-only** is on when you add the step, and it is your *database* that enforces it, not a check on the text of your SQL. The statement runs inside a read-only transaction (`BEGIN READ ONLY` on PostgreSQL, `START TRANSACTION READ ONLY` on MySQL), so the server refuses any write — including one hidden inside a function your statement calls. Turn **Read-only** off to `INSERT`, `UPDATE` or `DELETE`. When you do: * the step's output records `readOnly: false`, so a run that wrote is visible as one; * the statement still runs inside a transaction, so a failure rolls back; * `affectedRows` on the output tells you how many rows changed. The strongest version of this control is not in KnoxCall at all: give the step a database user that only has the grants it needs. Read-only steps deserve a read-only role. ## TLS TLS is on and fully verified by default. | Setting | What it checks | | - | - | | **Verify certificate and hostname** (default) | The certificate chains to a trusted CA **and** names the host you connected to. | | **Verify certificate only** | The chain, not the name. For a server whose certificate names something else. | | **No TLS** | Nothing. The credentials and every row cross the network in clear. | A server whose CA is not in the public trust store — Amazon RDS and Google Cloud SQL both use their own roots — will **fail** to connect on the default rather than quietly connecting insecurely. That is intentional: an unverified TLS session is not a weaker guarantee than a verified one, it is a different one, and an attacker on the path can present any certificate and read your database password. Whichever you choose is recorded on every run's output as `tls`. ## Where KnoxCall will and will not connect The Database step opens a raw TCP connection, so it goes through a chokepoint of its own. Before any socket is opened, KnoxCall resolves your host **once** and refuses to connect if the address is: * a private, loopback or link-local address (`10.x`, `192.168.x`, `127.0.0.1`, `::1`); * a cloud metadata address (`169.254.169.254`); * a KnoxCall host — the platform never sends your traffic to itself; * one of KnoxCall's own outbound addresses. It then connects to **that exact address**, so nothing can change what the name points at between the check and the connection. The certificate is still checked against the *name* you gave. Ports must be **1024–65535**, and a short list of well-known non-database services (Redis, Elasticsearch, memcached, HTTP proxies, Docker, Vault, and similar) is refused. Databases do not live on port 22 or port 80, and nothing good comes of a database driver handshaking with something that is not a database. Your database must be reachable from the public internet for this step to connect. If it is inside a private network, expose a read replica, put it behind a bastion you control, or use the HTTP Request step against an API in front of it. The same chokepoint governs the **Send Email** step when it is set to a custom SMTP host: the address rules above apply unchanged, and the port must be **465**, **587** or **2525**. Port 25 is refused — that is server-to-server mail delivery, not submission, and KnoxCall does not relay it. A private or internal mail relay is refused for the same reason a private database host is. ## Bounds | Setting | Range | What it does | | - | - | - | | **Row limit** | 1–1,000 (default 100) | How many rows the step keeps. | | **Timeout** | 1,000–30,000 ms (default 10,000) | The whole step's budget — see below. | **Timeout is one budget for the whole step.** Resolving your database's host, opening the connection and running the statement all draw on the same number — a step set to 10,000 ms will not spend 10,000 ms connecting and then another 10,000 ms querying. When the budget runs out the connection is closed and the step fails. **Row limit bounds the output, not the work.** Your database still produces every row a statement asks for; the limit decides how many are kept in the step's output. Put a `LIMIT` in your SQL to bound what the database does. When more rows came back than were kept, the output says so: `truncated: true`, with `rowCount` reporting the real total. ## Output ```json theme={"dark"} { "rows": [{ "id": 3, "email": "user3@example.com" }], "rowCount": 1, "fields": [{ "name": "id", "typeId": 23 }, { "name": "email", "typeId": 25 }], "truncated": false, "affectedRows": null, "readOnly": true, "tls": "verify-full", "driver": "postgres", "host": "db.example.com", "durationMs": 42 } ``` Reference a value downstream the usual way — `{{steps.lookup.output.rows[0].email}}`. ## Live and Test Secrets are separate per mode, so the Test copy of your connection Secret can point at a different database. **A Test run will dial whatever that Secret says** — if you leave it pointing at production, a Test run reaches production. Point it at a staging database, or at a read-only replica, before you rely on Test mode for safety. ## What it costs One operation per query, billed as an action — the same as an HTTP Request step. # Respond to Webhook Source: https://docs.knoxcall.com/workflows/respond-to-webhook Turn a workflow into an HTTP endpoint: hold the caller, run your steps, and answer with the status, headers and body you choose. # Respond to Webhook A webhook trigger normally answers straight away: KnoxCall accepts the delivery, queues the run and returns `202 Accepted`. That is the right behaviour for a provider firing events at you — they do not want your answer, they want an acknowledgement. Sometimes the caller *does* want an answer. A form that needs a validation result. An internal service that wants a lookup. A partner integration that expects JSON back. **Respond from the workflow** is that mode: the request is held open while the run executes, and a **Respond to Webhook** step decides what the caller receives. Pair it with a [Route](/essentials/routes/creating-routes) for authentication, rate limits and egress control, and you have an API endpoint you built on a canvas. ## Turning it on On the Trigger node, with **Trigger Type** set to *Webhook*: | Field | What it does | | - | - | | **Webhook Response** | *Reply immediately (202)* — the default and the behaviour every existing webhook has. *Respond from the workflow* — hold the caller. | | **Response Timeout (ms)** | How long to hold, between 250 ms and 30 000 ms. Defaults to 10 000 ms. | Switching an existing webhook to *Respond from the workflow* does not change its URL, its token or its authentication. ## The Respond to Webhook step Add a **Respond to Webhook** step anywhere in the graph. It sets three things: * **Status Code** — any code from 200 to 599. * **Response Headers** — a key/value list. A row can take its value from a Secret, which is how you sign your own response. * **Body** — JSON (validated before it is sent), plain text, or none. Everything supports `{{expressions}}`, so a body can be built from earlier steps — the status code and the Content-Type included. A status expression must resolve to a number between 200 and 599; if it does not, the step fails and you see the error on the run rather than a caller quietly receiving a 200. ## One answer per run A run answers its caller **once**. The first Respond to Webhook step to execute wins. So a workflow may hold more than one reply step, but only where the two can never run in the same execution. When you publish, KnoxCall checks exactly that, and it has to be able to *prove* it. Three shapes count as proof: * the **true** and **false** arms of a Condition; * two different routes of a **Router** in *first match* mode; * a step's **success** arm and its **error** arm, when that step is set to *Route errors to the error output*. (200 when the upstream call worked, 502 when it did not.) Anything else is refused, because anything else runs both. Two reply steps hanging off the same step run **both** — a plain fan-out is parallel, not a choice. So does a Router in *all matches* mode, an unlabelled Router branch (it follows every match), two steps on the same arm of a Condition, and two reply steps in disconnected parts of the canvas. A second reply step reachable *after* the first, and a reply step inside a **loop** body, are refused for the same reason. In each case one of the replies would be dead configuration and you would only find out from a caller. If you want two replies where the choice is made by a *pass-only-if* filter on the edges, put a Condition in instead — a filter is not something the publish check can prove exclusive. ## If the run takes too long If the window closes before a Respond to Webhook step is reached, the caller receives `202 Accepted` with the execution id: ```json theme={"dark"} { "success": true, "execution_id": "…", "status": "accepted" } ``` The run is **not** cancelled — it carries on to completion, and you can look it up by that execution id. Design your endpoint so the fallback is acceptable: a caller that waited 10 seconds and got a 202 should still be able to find out what happened. ## What a reply may and may not contain Two rules, and both exist because this endpoint answers from a KnoxCall domain: **Headers are allowlisted.** `Content-Type`, `Cache-Control`, `ETag`, `Retry-After`, `Link`, `Content-Disposition` and friends are allowed, as is any header name of your own beginning `x-`. Refused: `Set-Cookie`, `Location`, `Authorization`, CORS `Access-Control-*` headers, hop-by-hop headers, and the platform's own security headers. A workflow may not set a cookie on our origin, redirect from it, or relax its CORS policy. A disallowed header is reported when you publish, not silently dropped later. **The body is redacted; a header value is not.** Any Secret this run loaded is replaced with `[REDACTED]` in the response body before the bytes leave KnoxCall — a body is assembled out of run data, so a credential can end up there by accident. A header value is a slot you chose deliberately, so it is sent as written; that is what makes a Secret-backed signature header work. Do not template a Secret into the body and expect it to arrive. Replies are capped at 256 KB and 24 headers. A webhook reply is an answer, not a file transfer. ## Repeated and identical requests An endpoint answers **every** request. Two callers asking the same question with the same body — and one caller retrying after the 202 above — each get their own run and their own reply. That is deliberately different from a plain webhook. In *Reply immediately* mode a byte-identical redelivery inside five minutes is treated as the provider retrying an event it already sent us, and is collapsed to a single run; that is what stops a replayed Stripe delivery re-running your workflow. An endpoint is not an event feed, so the same rule would break it: the second caller would get `202 duplicate` and no answer at all. **Idempotency of the work is therefore yours**, exactly as it is for any HTTP API you write. If a request must be safe to retry, have the caller send a key of its own (a header or a body field), and use a [Storage](/workflows/storage) step to record it and short-circuit the second run. ## Rate limits Because a workflow-backed endpoint launches a run on every request, it counts against your workspace's workflow execution allowance — the same budget the dashboard, the API and every other launch path spend. A request over the limit is answered `429` with a `Retry-After` header, and no run is started. Retries of the same request count too, which is the bound that replaces the redelivery collapse described above. ## Related * [Triggers](/workflows/triggers) * [Creating workflows](/workflows/creating-workflows) * [Storage](/workflows/storage) # Storage Source: https://docs.knoxcall.com/workflows/storage A key/value store your workflows can read and write across runs — counters, cursors, de-duplication and locks, scoped to your workspace and to Live or Test. # Storage Everything else in a workflow run is temporary. Variables live for one execution; a checkpoint belongs to that execution alone. **Storage** is the exception: a small key/value store that outlives the run, so a workflow can remember something between executions without you standing up a database. It answers the three questions every real automation eventually asks: * *Have I already processed this record?* — write the id, check it next time. * *Where did I get to last time?* — keep a cursor. * *Is another run already doing this?* — take a lock. ## Where your data lives Storage entries belong to your workspace and to **one mode**. A key written by a Test run and a key of the same name written by a Live run are two different entries: neither can read or overwrite the other. That is the same separation Secrets, Routes and Clients already have, and it means you can exercise a workflow in Test without touching the counters and cursors your production automations depend on. Inside one workspace and mode you can group keys with an optional **namespace**, so two workflows can both use a key called `cursor` without colliding. ## Operations | Operation | What it does | | - | - | | **Get** | Read a key. Returns your default value (or `null`) when it is absent or expired. | | **Set** | Write a key, creating it or replacing what was there. | | **Set if…** | Compare-and-set. Write **only** if the key is free, or only if it currently holds a value you name. | | **Increment** | Add to a counter, atomically. Negative amounts count down. | | **Push** | Append an entry to a list. | | **Pop** | Remove and return one entry — the last (a stack) or the first (a queue). | | **Delete** | Remove a key. | | **List keys** | The key names in a namespace, with their sizes. Never their values. | ### Why "Set if…" matters **Set if…** is the operation that makes concurrency safe. KnoxCall runs your workflows on several workers at once, so two runs really can reach the same step at the same moment. A "read the value, then write it back" pattern loses one of the two updates whenever that happens. Set if… is decided by the database in a single step, so exactly one run can win: * **Only if the key is free** (the default) — the winner gets `applied: true`, everyone else gets `applied: false` and can take the other branch. Add a **time to live** and you have a lock that releases itself if the holder dies. * **Compare against an expected value** — turn off *Only if the key is free* and give the value the entry must currently hold. Useful for state machines: exactly one run moves `pending` to `claimed`. **Increment** and **Push** are decided by the database in the same way, so a counter never loses a tick and no list entry is ever dropped. ## Expiry Give an entry a **time to live** and it stops being readable when that time passes — an expired entry behaves exactly as if it had never been written, including for Set if…, which is what makes an expired lock claimable again. Between 1 second and 90 days; leave it blank for an entry that never expires. ## De-duplication, in three fields The most common use. Before doing the work: 1. **Set if…** with the key `seen:{{trigger.body.id}}`, the value `true`, *Only if the key is free* on, and a time to live of a day or a week. 2. Add a **Condition** on `{{steps.dedupe.applied}}`. 3. Only the `true` branch does the work. A repeat delivery takes the other branch. ## Limits | | | | - | - | | Key | 256 bytes | | Namespace | 128 bytes | | Value | 64 KB of JSON | | Keys | 10,000 per workspace, per mode | | List | 1,000 entries | | Time to live | 1 second – 90 days | Every limit **refuses** rather than trimming: a step that would exceed one fails with a message naming the limit and the size it measured, and nothing is written. A value that was silently shortened would be corruption that looked like success, and no key of yours is ever evicted to make room for another. If you need more than this, you want a real datastore — reach it with an **HTTP Request** step and keep the connection details in a Secret. ## Never store a credential here A storage value is data, not custody. KnoxCall refuses to publish a workflow whose step configuration contains something credential-shaped, exactly as it does for HTTP headers and email settings. Reference a Secret with `{{secrets.NAME}}` where the credential is actually needed; it is injected on the wire and never stored in the workflow. Values themselves are treated as your data throughout: they are never written to a run log, and **List keys** returns names and sizes only. ## Billing Each storage step counts as one **action** operation against your plan's monthly allowance, like an HTTP or email step. # Sub-workflows Source: https://docs.knoxcall.com/workflows/sub-workflows Call one workflow from another as a step — wait for its output or start it and carry on — with depth and fan-out limits and loops refused before they run. # Sub-workflows A large automation does not have to be one large canvas. The **Call Workflow** step runs another of your workflows as a step, so the pieces you keep re-drawing — notify the customer, reconcile the order, escalate to a human — become one workflow each, written once and called from everywhere. ## Adding the step Drop **Call Workflow** onto the canvas and pick the workflow to run. The picker offers your workspace's workflows in the mode you are currently in, and nothing else: a Live run can only call a Live workflow, a Test run only a Test one. The called workflow must be turned on: a workflow that is off is listed as *Disabled — not callable*, and choosing it is refused when you publish and again at run time, because turning a workflow off has to stop it everywhere. | Field | What it does | | - | - | | **Workflow** | The workflow to run. Chosen from the list, not typed. | | **Input** | The JSON the called workflow receives as its trigger payload. Supports `{{variables}}`. | | **Mode** | *Wait for it to finish*, or *Start it and continue*. | | **Timeout** | Wait mode only: how long before this step gives up. 5 seconds to 24 hours; 5 minutes by default. | The called workflow reads what you map in **Input** as `{{trigger.}}`. Nothing is forwarded automatically — what the child sees is always something you wrote, which is what stops an upstream payload leaking into a workflow that was not expecting it. ## Waiting, without holding anything up In **wait** mode your run pauses until the called workflow finishes, then continues with its output: ``` {{steps.call-1.output.invoiceId}} ``` The pause is durable, not a held connection. Your run is checkpointed and set aside; it comes back when the called run finishes. A workflow that waits two hours for a called workflow costs nothing while it waits, and it survives a restart. The step's output carries: | Field | | | - | - | | `executionId` | The run that was started. The run log links straight to it. | | `status` | `completed` in wait mode, `queued` when you started it and carried on. | | `output` | The called workflow's own output — only its output, never its steps or its secrets. | | `durationMs` | How long the called run took. | In **start and continue** mode the step returns the new run's id immediately and your run moves on. Use it for work whose result you do not need: a notification, an audit write, a slow batch you will check on later. ## When the called workflow fails A failed called run fails the step, with the child's error attached — so everything you already use for a failing step works here too: **Retry**, **Continue**, the **error branch**, or a wired **Error Handler**. A retry starts a fresh run rather than re-reading the failed one. A **timeout** fails the step and leaves the called run going. It is a real run that has made real calls, and stopping it is your decision — open it from the run log and cancel it if that is what you want. ## The limits, and why they are there Every one of these refuses the step rather than quietly doing something else. * **Depth 5.** A workflow may be called from a workflow that was called from a workflow, five levels deep. Past that the step fails, naming the limit. * **100 called runs per run.** One run may start at most a hundred called runs. If you need to process a thousand records, put the loop *inside* the called workflow rather than calling it once per record. * **No loops.** A workflow may not call itself, directly or through a chain that comes back to it. This is refused when you publish — the builder tells you which step — and refused again at run time, in case the other workflow changed after you published. * **Your workspace, your mode.** A call never crosses into another workspace, and never between Live and Test. * **The called workflow is on.** A disabled workflow is not callable — the same switch that stops its own triggers stops it being called. Called runs are ordinary runs: they appear in your run history with a trigger of *sub-workflow*, they count once against your monthly operations, and each one has its own step log. The step that called them counts once, as a control step — nothing is billed twice. ## Seeing what happened The called run appears in the run history like any other, and the run log's entry for the **Call Workflow** step links directly to it. Open it to see the child's own steps, its input and its output. # Templates Source: https://docs.knoxcall.com/workflows/templates Start from a pre-built workflow instead of a blank canvas. Templates land as a disabled draft so you can wire in your own connections and secrets first. # Templates Templates are ready-made workflows you can start from instead of building on a blank canvas. Each one is a validated graph covering a common pattern — you fill in your own connections, secrets, and endpoints, then publish. ## Using a Template 1. In **Workflows**, click **Templates** (or **New from template**). 2. Browse the gallery and open a template to preview its nodes. 3. Click **Use this template**. The template is created as a new workflow **in a disabled draft**: * **Disabled** — it won't run until you review and enable it, so a half-configured template never fires by accident. * **Draft** — it opens in the builder for you to customize before publishing. ## Customizing After Import Before enabling, replace the template's placeholders with your own values: 1. **Connections** — point any app-connector nodes at your [Connections](/workflows/connections). 2. **Secrets** — swap placeholder credentials for `{{secrets.NAME}}` references. 3. **Endpoints & fields** — set real URLs, recipients, and body fields. 4. **Trigger** — configure the [trigger](/workflows/triggers) (schedule, webhook, app event, …). Run it manually (or test individual nodes) to confirm it behaves as expected, then **Publish** and toggle **Enabled**. ## Plan & Limits Creating from a template counts toward your plan's workflow entitlements (the same limits as building one by hand). If you're at your workflow limit, the import is blocked until you free a slot or upgrade. ## Next Steps Build or customize a workflow step by step Let the copilot draft a workflow from a prompt # Triggers Source: https://docs.knoxcall.com/workflows/triggers Every way to start a workflow: manual, schedule, webhook, inbound webhook, route event, and app events (instant provider webhooks and polling). # Triggers A workflow's single **trigger** node decides when it runs. Beyond manual and API invocation, KnoxCall supports scheduled, webhook, route-event, and app-based triggers — all backed by durable, exactly-once delivery. ## Trigger Types | Type | Fires when… | | - | - | | **Manual** | You run it from the UI or the API | | **Schedule** | A cron expression comes due | | **Webhook** | An HTTP request hits the workflow's signed URL | | **Inbound Webhook** | A shared inbound webhook fans out to this workflow | | **Route Event** | A KnoxCall route processes a request (`proxy_event`) | | **App Event** | A connected app sends a provider webhook (instant) | | **App Polling** | Polling a connected app surfaces a new item | | **Another workflow fails** | Any other workflow in this environment fails (`workflow_error`) | Triggers are synced from the workflow definition to a trigger registry on **publish**, so changing a trigger only takes effect when you publish. ## Error handler — when another workflow fails A workflow with the **Another workflow fails** trigger runs whenever a *different* workflow in the same environment ends in a failure. It is how you send failures to Slack, open a ticket, or page someone, without adding an error branch to every workflow you own. **Scope.** Leave **Workflows to watch** empty and it handles failures from every workflow in this environment — one handler is usually all you want. List specific workflows to narrow it. Three rules are enforced rather than left to configuration: * **A workflow never handles its own failures.** It is excluded from its own watch set. * **An error handler's own failure does not start another error handler.** If it did, a broken handler would launch a copy of itself on every failure, forever. Its failure is recorded in the run history and stops there — so check that history if a handler goes quiet. * **A disabled handler stays disabled.** Turning one off stops it, including one KnoxCall turned off itself (see below). The run receives the failure as its trigger payload: ```json theme={"dark"} { "execution": { "id": "…", "workflowId": "…", "workflowName": "Nightly sync", "triggerType": "schedule", "startedAt": "2026-09-10T10:00:00.000Z", "failedAt": "2026-09-10T10:00:05.000Z", "errorMessage": "Node http_1 failed: HTTP 500 from upstream", "sandbox": false } } ``` Live and Test are separate: a Test-mode failure only ever reaches a Test-mode handler. ## When a workflow keeps failing After **five consecutive failed runs**, KnoxCall disables the workflow and emails the workspace's owners and admins. Nothing is deleted, and successful runs before that point are untouched. The count is of consecutive *runs*, and a run only counts as failed once it has exhausted whatever per-node retry policy you configured — so five here means five genuine failures, not five attempts. A single successful run resets it. The workflow stays off until you turn it back on. KnoxCall will not re-enable it for you: the point is that somebody looks at why it broke first. ## Manual & API A **Manual** trigger runs on demand. From the API, queue a run against the public resource: ```bash theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) curl -X POST https://api.knoxcall.com/v1/workflows/{id}/execute \ -H "Authorization: Bearer $TOKEN" \ -d '{"input": {"any": "data"}}' ``` The `input` object is available in the workflow as `{{trigger.data.*}}` (and `{{input.*}}`). See [Using the API](/workflows/api). ## Schedule Run on a cron schedule: ```yaml theme={"dark"} Trigger Type: Schedule Schedule: "0 * * * *" # every hour, on the hour ``` Scheduling is **exactly-once across the worker fleet** — a due schedule fires a single run even with multiple workers, using row-locked claiming (no leader election, no duplicate runs). ## Webhook A **Webhook** trigger gets a signed, per-workflow URL on publish: ```text theme={"dark"} https://api.knoxcall.com/hooks/wf/{token} ``` `POST` your payload to it; the raw body is available in the workflow. The endpoint is rate-limited per token and supports bearer and HMAC signature verification. The payload is delivered as the trigger input. The old admin route `POST /admin/webhooks/workflows/{id}/trigger` has been removed. Use the workflow's `/hooks/wf/{token}` URL (or the `/v1` execute endpoint) instead. ## Inbound Webhook If you already receive provider webhooks through a shared **inbound webhook**, target a workflow from it. Only verified deliveries fan out, and the match is pinned to the workflow's mode (Live/Test). ## Route Event Fire when requests pass through your routes: ```yaml theme={"dark"} Trigger Type: Route Event # underlying triggerType: proxy_event Event Types: - request.completed Route Filter: (optional) Environment Filter: (optional) ``` Valid event types: `request.received`, `request.success`, `request.error`, `request.client_error`, `request.server_error`, `request.timeout`, `request.completed`. You can narrow matches with a route filter, environment filter, and field conditions. **Request and response bodies are not included.** The trigger carries the request method, path, headers, query parameters and IP, and the response status, headers and latency — everything you route and filter on — but the payload bodies read `[body logging disabled]`. Proxied payloads are not copied into workflow run history unless the deployment has explicitly opted into body persistence (`PROXY_LOG_BODIES`, off by default, and the same switch that governs whether bodies appear in request logs). It is one decision for both stores: if your request logs show `[body logging disabled]`, so will your workflow runs. ## App Triggers Connected apps ([Connections](/workflows/connections)) can start workflows two ways. ### Instant (provider webhooks) For apps that support it (e.g. **GitHub** issue-opened, **Stripe** events), publishing the workflow **subscribes** a provider webhook automatically; disabling or deleting the workflow **unsubscribes** it. Deliveries arrive at `POST /hooks/apps/:token`, are signature-verified over the raw bytes, and are **de-duplicated** so a provider redelivery runs the workflow only once. ```yaml theme={"dark"} Trigger Type: App Event Connection: my-github # a saved GitHub connection Event: issue_opened ``` ### Polling For apps without per-connection webhooks (e.g. **Slack** messages), KnoxCall polls on a schedule and fires on new items. The first poll **arms the cursor without firing** (so you don't get a backlog flood), and polling errors back off automatically. One poll starts at most **25 runs**. A burst bigger than that is not dropped: the poll starts the oldest 25, remembers the last item it started, and the remainder fires on the following poll — so a backlog drains at 25 items per polling interval, each item firing exactly once. ```yaml theme={"dark"} Trigger Type: App Polling Connection: my-slack Resource: new_channel_message ``` ## Delivery Guarantees * **Exactly-once** — schedules, polling, and instant webhook redeliveries all de-duplicate; a single logical event produces a single run. * **Durable** — a queued run survives a worker restart and resumes. * **Mode-scoped** — triggers only fire workflows in their own Live/Test mode. ## Notifications on Completion To be notified when a run finishes (rather than starting one), subscribe an outbound webhook to `workflow.execution.completed` / `workflow.execution.failed`. See [Using the API](/workflows/api#webhook-events). ## Next Steps Connect the apps that power app triggers Execute and manage workflows via /v1 # Wait & Approval Source: https://docs.knoxcall.com/workflows/wait-and-approval Pause a workflow until a person approves it or a one-time callback URL is used, then carry on with what they sent — without holding anything open in between. # Wait for Event Some automations should not finish on their own. A refund over a threshold wants a human's say-so. A provisioning run has to wait for a partner system to call back. A document has to be signed before the next step means anything. The **Wait for Event** step is that pause. It stops the run, hands out a one-time link, and does nothing at all until somebody uses it — or until the wait runs out. ## It costs nothing while it waits A waiting run is not a held connection or a sleeping worker. KnoxCall writes the run's state to an encrypted checkpoint and puts it down; when the answer arrives, the run picks up on the very next step with everything it knew before. A wait of thirty seconds and a wait of three weeks cost the same: nothing. That is also why a wait survives a deploy, a restart, or a machine going away. There is no process sitting somewhere holding your place. ## Two modes ### Wait for a callback The step mints a single-use URL. Whoever holds it can `POST` to it, and whatever they send becomes the step's output: ```json theme={"dark"} { "resumed": true, "timedOut": false, "mode": "webhook", "payload": { "signed": true, "documentId": "doc_123" }, "receivedAt": "2026-09-09T04:11:02.114Z" } ``` The link is emailed to the people you name when the wait begins, and at least one address is required — email is currently the only way the link leaves KnoxCall, so a callback wait with nobody to send it to could only ever time out. ### Wait for an approval The step emails **approve** and **reject** links to the people you list. Each person gets their own pair, which is what makes the recorded decision trustworthy: the respondent is the address KnoxCall sent the link to, never something the person typed. ```json theme={"dark"} { "resumed": true, "timedOut": false, "mode": "approval", "approved": true, "decision": "approve", "respondent": "ops@yourcompany.com", "comment": "Checked the invoice — fine to release.", "decidedAt": "2026-09-09T04:11:02.114Z" } ``` The **first decision wins**, and it is enforced rather than merely intended: the answered step is marked the instant a decision is recorded, so a second link presented in the same breath as the first is refused and cannot overwrite it. Every other link for that wait then stops working — including the same person's other button. ## Who KnoxCall will email Approval and callback mail goes out on **KnoxCall's own signed sender**, because the recipient's only defence against a forged approval request is that it demonstrably came from us. That puts two limits on it, both deliberate: * **The subject line is ours.** It names the workflow and nothing else. There is no field for it. Your **Message** still appears in the mail, shown as a quote attributed to your workspace. * **The recipient must be somebody you can prove a relationship with** — a member of this workspace, or an address at (or beneath) a domain you have verified under *Settings → Sending domains*. Anything else is refused before the wait starts, and the step says which address and why. To email someone outside both of those — a customer, a partner — use the **Send Email** step on your own provider credential, where your provider account enforces its own verification. ## The timeout branch Every wait has a deadline, between one minute and thirty days. When it passes, the run continues down a **connection labelled `timeout`**: * draw a connection from the Wait step to whatever should happen when nobody answers; * open the connection and name it `timeout`. The other connections leaving the step are the *answered* path, and the two never both run. Branch on `{{steps.wait.approved}}` after an approval if you want different behaviour for approve and reject. If you would rather the run simply stop, set **On timeout** to *Fail the run*. What the step will not do is quietly go nowhere: if it is set to branch and no `timeout` connection is wired, the run fails and says so. ## What the links are, and what they are not A resume link is a **capability**: whoever holds it can continue that one run, once. Treat it the way you would treat a password reset link. * The link is 256 bits of randomness. KnoxCall stores only its SHA-256 hash, so the link cannot be recovered from our database, our logs, or your run history — it exists in the email we sent and nowhere else. * It works **once**. A second use, a use after the deadline, and a link that never existed all get the same "not found": the endpoint will not tell anyone which. * Clicking it opens a **confirmation page**. Nothing happens until the button on that page is pressed, so a mail scanner or link preview that fetches the URL cannot approve anything on your behalf. * It is scoped to one run, one step and one mode. A Test-mode link can never resume a Live run. * **Turning the workflow off kills its pending links.** Disabling a workflow stops it everywhere: no new run starts, and no waiting run resumes. Any approval links already out in people's inboxes stop working from that moment, they get the same "not found" as an expired one, and the waiting runs are marked failed with `workflow_disabled` so your run history says why. Turning the workflow back on does not revive them — a decision made about a workflow that was switched off is not a decision we will act on. If you need the approval, re-run the workflow once it is enabled again. Resume links spend the same per-workspace execution budget as every other way of starting or continuing a run. A burst of resumes is rate-limited exactly as a burst of executions is. ## Inside a loop A Wait step works inside a **For each** loop as long as the loop runs one item at a time (batch size 1). The loop stops on the item it is on, and resumes on that same item with everything it has already collected intact. A loop set to run items in parallel cannot pause — there would be several half-finished items and no single place to record them — so a Wait step inside one fails with a message saying to set the batch size to 1. ## What the run history shows The step appears as **waiting** while it is paused, and completes when the answer arrives. The completed step records who answered and what they said. It never records the link — that is the point of storing only a hash. ## Limits | | | | - | - | | Wait length | 1 minute to 30 days | | Approvers per step | 20 | | Callback payload | 64 KB | | Comment | 1,000 characters | | Message | 2,000 characters | | Link uses | 1 | ## Related * [Creating workflows](/workflows/creating-workflows) * [Respond to Webhook](/workflows/respond-to-webhook) — answer an HTTP caller from a run * [Storage](/workflows/storage) — state that outlives a single run # Workflows Overview Source: https://docs.knoxcall.com/workflows/workflows-overview Build durable, automated workflows with a visual builder. Chain API calls and app connectors, branch on conditions, loop, run AI steps, and handle errors — with crash-safe execution. # Workflows Overview Workflows let you build automated processes that chain API calls, app connectors, and AI steps; branch on conditions; loop over data; and recover from errors — all through a visual builder. Executions are **durable**: they run on a dedicated worker backed by a job queue, survive restarts, resume delays on time, and can be cancelled and retried from a failed step. ## What Are Workflows? A **workflow** is a graph of nodes that runs in response to a trigger or a manual/API invocation. **Example workflow:** ```text theme={"dark"} Trigger: Incoming webhook from Stripe ↓ HTTP Request: Validate payment with your backend ↓ Condition: Is payment successful? ↓ True ↓ False Send confirmation email Send Slack alert (app connector) ↓ ↓ Update CRM record Create support ticket ``` ## Key Concepts ### Nodes Nodes are the building blocks of a workflow. Each node performs one action. Node types are grouped by category: | Category | Nodes | | - | - | | **Trigger** | Trigger (manual, schedule, webhook, inbound webhook, route event, app event, app polling) | | **Action** | HTTP Request, Send Email, Send SMS, plus every **app connector** operation (`app::`) | | **AI** | AI (classify / extract / summarize / generate) | | **Control** | Condition, Router, Loop, Error Handler | | **Transform** | Code Block (sandboxed JavaScript), Data Transform | | **Utility** | Delay, Merge, Set Variable | The Code Block node runs real JavaScript in a sandbox now — it is no longer a pass-through stub. See [Code Block Node](#code-block-node). ### Edges Edges connect nodes and define execution flow. The engine uses **frontier traversal** with branch routing: a node runs only when its incoming edges are resolved and at least one is active. Condition and Router nodes activate specific outgoing edges (by `sourceHandle`), and inactive branches are recorded as **skipped** steps so the canvas greys them out — every node no longer runs on every execution. ### Variables Reference data from anywhere in the run with `{{ ... }}` expressions. The following namespaces are available: ```text theme={"dark"} {{trigger.data.email}} # the trigger node's output (also {{trigger-node.*}}) {{http_1.body.customer_id}} # any node's output, keyed by node id {{input.foo}} # the current node's direct input {{loop.item}} {{loop.index}} # inside a Loop body: current item / 0-based index {{vars.counter}} # values set by a Set Variable node {{secrets.STRIPE_KEY}} # a stored secret, injected at runtime (redacted in logs) {{env.REGION}} # an environment variable for the active environment ``` Unknown names are left literal (they are not replaced with `undefined`). For an HTTP Request node the response payload is exposed under `.body` (status, statusText, headers, body, url, method, success). ## Node Types ### Trigger Node Every workflow starts with exactly one trigger node that determines when it runs: * **Manual** — run on demand from the UI or API. * **Schedule** — run on a cron expression (exactly-once across the worker fleet). * **Webhook** — run when an HTTP request hits the workflow's `/hooks/wf/:token` URL. * **Inbound Webhook** — fan out from a shared inbound webhook to this workflow. * **Route Event** — run when a KnoxCall route processes a request (`proxy_event`). * **App Event** — run instantly when a connected app sends a provider webhook (e.g. a GitHub issue). * **App Polling** — run when polling a connected app surfaces a new item. See [Triggers](/workflows/triggers) for configuration and delivery details. ### HTTP Request Node Make HTTP requests to external APIs. Config is nested under `data.config`: ```json theme={"dark"} { "type": "http_request", "data": { "type": "http_request", "config": { "method": "POST", "url": "https://api.example.com/users", "headers": { "Content-Type": "application/json" }, "body": { "email": "{{trigger.data.email}}", "name": "{{trigger.data.name}}" } } } } ``` **Output** (response payload under `body`): ```javascript theme={"dark"} { status: 200, statusText: "OK", headers: { ... }, body: { id: "user_123" }, url: "...", method: "POST", success: true } ``` ### App Connector Nodes Instead of hand-rolling an HTTP Request against a provider, use a **connected app**. Each connector operation appears as an action node (for example *Slack → Send Message*, *SendGrid → Send Email*, *Notion → Create Page*). The node references a saved [Connection](/workflows/connections) and KnoxCall injects the credentials and calls the provider through its SSRF-pinned, host-allowlisted transport. Ten apps ship in the catalog: SendGrid, Stripe, Twilio, Slack, GitHub, Notion, Linear, Discord, Airtable, and Telegram. ### AI Node Run an Anthropic model as a workflow step with **structured output** — no prompt-parsing glue. Four operations: * **Classify** — pick one label from a candidate list. * **Extract** — pull named fields out of text into a typed object. * **Summarize** — condense text to a target length. * **Generate** — free-form generation with a token cap. ```json theme={"dark"} { "type": "ai", "data": { "type": "ai", "config": { "operation": "classify", "text": "{{trigger.data.message}}", "labels": ["billing", "technical", "sales"] } } } ``` AI steps use your tenant Anthropic key when one is configured (Integrations), otherwise the platform key. They count as weighted operations (×3) and draw from your monthly AI-token budget. See [AI in Workflows](/workflows/ai). ### Code Block Node Run custom code in a secure sandbox — for transforms, calculations, and formatting. **JavaScript/TypeScript** runs in an in-process QuickJS-WASM sandbox; **Python** runs in a network-less container jail. Pick the language in the node's inspector. ```javascript theme={"dark"} // JavaScript — `input` is the upstream node's output (for one incoming edge). // For an HTTP Request node the response body is under `input.body`. const items = input.body.items; const total = items.reduce((sum, item) => sum + item.price, 0); return { total, itemCount: items.length }; ``` ```python theme={"dark"} # Python — the same contract: `input` (and `context`) are available, and you # `return` the result. print() output is captured as step logs. items = input["body"]["items"] return {"total": sum(i["price"] for i in items), "itemCount": len(items)} ``` For fan-in (multiple incoming edges), `input` is an array of `{ nodeId, output }` entries keyed by node **id**. Neither sandbox has network, filesystem, or timer access; each enforces a wall-clock timeout and a memory cap. Python requires Docker on the workflow worker (self-hosted installs without Docker get a clear "requires Docker" error). ### Data Transform Node Reshape data declaratively (map/pick/rename fields) without writing code — useful when a Code Block would be overkill. ### Condition Node Branch on a structured list of conditions (not a JavaScript string): ```json theme={"dark"} { "type": "condition", "data": { "type": "condition", "config": { "conditions": [ { "type": "equals", "path": "$.status", "value": 200 } ], "logic": "AND" } } } ``` Each condition has a `type`, a JSONPath `path`, and a `value`. Conditions are combined with `logic` (`AND`/`OR`). The node activates its **True** or **False** outgoing edge; the other branch is skipped. **Supported types:** `equals`, `not_equals`, `greater_than`, `less_than`, `greater_or_equal`, `less_or_equal`, `contains`, `starts_with`, `ends_with`, `exists`, `is_empty`, `regex`. ### Router Node Route to one or more of several named paths: ```json theme={"dark"} { "type": "router", "data": { "type": "router", "config": { "executionMode": "sequential", "routes": [ { "id": "route_high", "name": "high", "condition": [ { "type": "equals", "path": "$.priority", "value": "high" } ] }, { "id": "route_default", "name": "default", "isFallback": true } ] } } } ``` `executionMode` is `sequential` (first match wins) or `parallel` (every matching route runs). A route with `isFallback: true` handles input that matches no other route. ### Loop Node Iterate over an array or repeat while a condition holds. Loops are an engine construct: the loop body is a real subgraph (branches and nested loops allowed), each iteration produces linked step rows, and `while` loops thread the previous iteration's output. ```json theme={"dark"} { "type": "loop", "data": { "type": "loop", "config": { "loopType": "array", "maxIterations": 100, "arrayConfig": { "arrayPath": "$.users", "batchSize": 1 } } } } ``` Reference the current element with `{{loop.item}}` and its position with `{{loop.index}}`. `loopType` is `array` (via `arrayConfig`) or `while` (via `whileConfig`); `maxIterations` is a required safety cap. `batchSize` > 1 processes items in parallel batches. ### Delay Node Pause execution: ```json theme={"dark"} { "type": "delay", "data": { "type": "delay", "config": { "delayType": "duration", "duration": 5, "unit": "seconds" } } } ``` Short delays sleep in-process (abortable on cancel). **Long delays are durable**: the execution suspends to a `waiting` state with an encrypted checkpoint and resumes on schedule — even across a worker restart. `delayType` is `duration` (with `duration` + `unit`: seconds/minutes/hours) or `until` (with an ISO timestamp). ### Merge Node Join multiple incoming branches back into one. Modes: `all` (wait for every non-skipped branch), `first` (take the first to arrive), or `n` (wait for N). Emits an array or a merged object. ### Set Variable Node Write a value into the `{{vars.*}}` namespace for later nodes to read. ### Error Handler Node React to an error on its input and apply a strategy: ```json theme={"dark"} { "type": "error_handler", "data": { "type": "error_handler", "config": { "onError": "fallback", "fallbackValue": { "status": "degraded" }, "retryConfig": { "maxAttempts": 3, "backoff": "exponential", "initialDelay": 1000 } } } } ``` `onError` is `continue`, `retry`, or `fallback`. Retries are **durable** (scheduled with backoff via the queue, not a blocking sleep), so a retrying step survives a restart. ## Durable Execution Workflows run on a dedicated background worker backed by a durable job queue (pg-boss). This gives you: * **Crash safety** — if the worker restarts mid-run, the execution resumes from its last checkpoint; completed steps are never re-run. * **Real cancellation** — cancelling aborts in-flight work (including HTTP requests), not just a database flag. * **Timeouts** — a run that exceeds its `timeout_seconds` is marked `timed_out`. * **Exactly-once scheduling** — scheduled and polling triggers fire once across the whole worker fleet (row-locked, no duplicates). * **Per-tenant concurrency** — runs beyond your concurrency limit are queued, not dropped. **Execution states:** `queued → running → { completed | failed | cancelled | timed_out }`, with `running → waiting → running` for durable delays/retries and `paused` for concurrency deferral. ## Executing Workflows ### Manual Execution (UI) 1. Open the workflow and click **Run**. 2. Optionally provide input data. 3. Watch each node light up live on the canvas. ### Via the public API Workflows are a first-class `/v1` resource. Queue a run with a short-lived OAuth token (see [Authentication](/api-reference/authentication)): ```bash theme={"dark"} # Mint a 1-hour OAuth token (client_credentials) — see /api-reference/authentication TOKEN=$(curl -s -X POST https://api.knoxcall.com/oauth/token \ -u "$KNOXCALL_CLIENT_ID:$KNOXCALL_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r .access_token) curl -X POST https://api.knoxcall.com/v1/workflows/{id}/execute \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -H "X-Idempotency-Key: onboarding-42" \ -d '{"input": {"email": "user@example.com"}}' ``` The run is queued and the call returns `201` immediately: ```json theme={"dark"} { "data": { "id": "execution-uuid", "workflow_id": "{id}", "status": "queued" }, "meta": { "request_id": "550e8400-e29b-41d4-a716-446655440000" } } ``` Sending the same `X-Idempotency-Key` again returns the **same** execution (with an `X-Idempotent-Replay: true` header) instead of starting a second run. See [Using the Workflows API](/workflows/api) for the full CRUD + executions surface. ### Via a webhook A workflow with a Webhook trigger gets a signed URL of the form `https://api.knoxcall.com/hooks/wf/{token}`. `POST` to it to run the workflow. See [Triggers](/workflows/triggers). ## Monitoring Executions * **Live progress** — nodes highlight as they start; success/failure and duration show per node; skipped branches are greyed. * **Execution history** — the **Executions** tab lists every run with status, start time, duration, and trigger type. * **Execution details** — open a run to see each step's (redacted) input/output, errors, and timing. Secret values always appear as `[REDACTED]`. * **Webhook notifications** — subscribe an outbound webhook to `workflow.execution.completed` / `workflow.execution.failed` to be notified when runs finish. ## Versioning, Draft & Publish The workflow you edit is a **draft**; the workflow that runs is the **published** version. Autosave writes to the draft without affecting live runs. **Publish** validates the graph, snapshots a new version, and switches live behavior. Every execution pins the `workflow_version` it ran against. **One-click rollback is available:** open the **Versions** tab and **Restore** any prior version (it loads into your draft, so you review before publishing). ## Templates Start from a pre-built [Template](/workflows/templates) instead of a blank canvas. Templates land as a **disabled** draft so you can wire in your own connections and secrets before enabling. ## Best Practices 1. **Start simple** — Trigger → HTTP Request, then add Condition/Router as needed. 2. **Name nodes descriptively** — "Fetch Customer Data", not "HTTP Request 1". 3. **Use connections and secrets** — never hard-code credentials in a node. 4. **Add error handling** on external calls and critical paths. 5. **Test before enabling** — run manually and use per-node Test in the inspector. 6. **Watch executions** — check history for failure patterns and slow steps. ## Next Steps Step-by-step build guide Connect Slack, GitHub, Stripe, and more Schedule, webhook, and app triggers AI steps and the workflow copilot Start from a pre-built workflow Manage and run workflows via /v1 *** * **Level**: intermediate * **Time**: 10 minutes `workflows`, `automation`, `orchestration`, `visual-builder`, `durable`