BYO (bring-your-own) API keys
Many apps integrate with services LinkWorld doesn’t ship a platform
skill for — ProxyCurl, Hunter, Stripe, SendGrid, SerpAPI, or any
niche SaaS API. The right pattern: the tenant brings their own key,
your app reads it through ctx.secrets, and you never see it in
plaintext.
This is a thin layer over ctx.secrets
— this page focuses on the flow: declaring, collecting, and using a
tenant-provided key.
Two-tier model
Section titled “Two-tier model” ┌─────────────────────────────┐ │ ctx.secrets.get('STRIPE') │ └──────────────┬──────────────┘ │ ┌─ Tenant override (their key) ─┐ │ │ found? not found │ │ ▼ ▼ return tenant key ┌─ Dev-default (your seed key) ─┐ │ │ found? not found │ │ ▼ ▼ return your key return null- Tenant-override wins. Set by the tenant admin in the app-settings UI at install time (or anytime after).
- Dev-default is a fallback you (the dev) seed once with
linkworld secrets set. Useful for: a shared sandbox key during development, a freemium tier where you front the API costs, a default for non-critical APIs.
If neither is set, ctx.secrets.get() returns null and your code
must handle it cleanly.
Canonical flow
Section titled “Canonical flow”1. Declare the secret in your manifest
Section titled “1. Declare the secret in your manifest”install_settings: - key: PROXYCURL_KEY label: "ProxyCurl API key" type: secret required: true help: "Sign up at proxycurl.com. Used to enrich LinkedIn profiles." - key: HUNTER_KEY label: "Hunter.io API key" type: secret required: false help: "Optional — used to verify email addresses. Without it, deliverability defaults to 'unknown'."The type: secret field tells the platform:
- Render a password-style input in the install UI
- Store in
linkworld_app_secrets(AES-256-GCM) - Strip from
ctx.config— only readable viactx.secrets.get() - Never re-display after creation (tenant must rotate to change)
2. Read at request time
Section titled “2. Read at request time”async def on_chat(ctx, message): key = await ctx.secrets.get("PROXYCURL_KEY") if key is None: await ctx.reply( "I need a ProxyCurl key to look up profiles. " "Please add it in **App settings → ProxyCurl API key**." ) return
profile = await fetch_profile(key, message.text) await ctx.reply(f"Found: {profile['headline']}")export async function onChat(ctx: Context, message: ChatMessage) { const key = await ctx.secrets.get('PROXYCURL_KEY') if (!key) { await ctx.reply( 'I need a ProxyCurl key to look up profiles. ' + 'Please add it in **App settings → ProxyCurl API key**.' ) return } const profile = await fetchProfile(key, message.text) await ctx.reply(`Found: ${profile.headline}`)}3. Handle the not-configured case
Section titled “3. Handle the not-configured case”ctx.secrets.get() returning null is a normal state — tenants can
install your app before they’ve signed up for the third-party
service. Three patterns, in order of preference:
| Case | Response |
|---|---|
| Required key, missing | Tell the user where to set it, name the field exactly as it appears in the UI. Don’t crash. |
| Optional key, missing | Degrade gracefully — disable that feature, surface a one-liner in the UI explaining what they’d unlock. |
| Multiple required keys, some missing | List all that are missing in one message. Don’t drip-feed errors. |
❌ Anti-patterns
Section titled “❌ Anti-patterns”Don’t: skip the manifest declaration
Section titled “Don’t: skip the manifest declaration”# ❌ Reading a key you never declaredkey = await ctx.secrets.get("MY_SECRET_KEY")If you don’t declare the key in install_settings, no UI ever
prompts the tenant to set it. Your app appears installed but
nothing works. The platform’s install-experience surfaces only
declared secrets.
Don’t: ship your dev key as a “default”
Section titled “Don’t: ship your dev key as a “default””# ❌ Setting your personal ProxyCurl key as the dev-defaultlinkworld secrets set PROXYCURL_KEY "Bearer YOUR-PERSONAL-KEY"Then every tenant who installs your app and doesn’t enter their own key is silently making requests against your personal billing. Dev-defaults are only for keys you intentionally pay for on everyone’s behalf.
For a key the tenant must bring, declare it required: true and
don’t set a dev-default — ctx.secrets.get() will return
null, and your code’s graceful error message guides them to set
their own.
Don’t: store the key in ctx.kv after first read
Section titled “Don’t: store the key in ctx.kv after first read”# ❌ "Caching" the secret in KV to avoid the lookup costkey = await ctx.secrets.get("PROXYCURL_KEY")await ctx.kv.set("proxycurl_cached", key) # NEVERctx.secrets.get() is already cheap (lookup is in-memory after
first decryption per container lifetime). Copying the value into
KV bypasses the encryption guarantees and trips the
credential-in-KV lint rule.
Don’t: pass the key through chat context
Section titled “Don’t: pass the key through chat context”# ❌ Embedding the key in a chat message or LLM promptawait ctx.reply(f"Using key {key} to look up...")LLM contexts leak. Prompt-caching might persist the value. Chat
messages get logged. Treat the secret as never-printable from the
moment you await ctx.secrets.get().
When this doesn’t apply
Section titled “When this doesn’t apply”- The platform already provides the integration. If LinkWorld ships an OAuth-mediated skill (M365, Odoo, LinkedIn, WhatsApp, Lexoffice, Business Central…) the tenant connects once through the platform’s settings UI and your app calls the scoped tool without ever touching credentials. See scope catalog.
- The credential is per-end-user, not per-tenant. If every end
user inside the tenant brings their own key (rare — usually a
sign you’ve modeled the data wrong), you’ll need to encode the
user identity in the key name (
STRIPE_KEY:<user_id>). Talk to us before going down this path.
Related
Section titled “Related”- Storing credentials — the broader rule about what goes in secrets vs KV.
- Secrets reference — storage internals, CLI commands, local-dev fallback.
- Install settings — full manifest field reference for the install-time UI.