Skip to content

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.

┌─────────────────────────────┐
│ 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.

linkworld.yaml
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 via ctx.secrets.get()
  • Never re-display after creation (tenant must rotate to change)
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}`)
}

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:

CaseResponse
Required key, missingTell the user where to set it, name the field exactly as it appears in the UI. Don’t crash.
Optional key, missingDegrade gracefully — disable that feature, surface a one-liner in the UI explaining what they’d unlock.
Multiple required keys, some missingList all that are missing in one message. Don’t drip-feed errors.
# ❌ Reading a key you never declared
key = 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””
Terminal window
# ❌ Setting your personal ProxyCurl key as the dev-default
linkworld 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 cost
key = await ctx.secrets.get("PROXYCURL_KEY")
await ctx.kv.set("proxycurl_cached", key) # NEVER

ctx.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 prompt
await 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().

  • 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.