Skip to content
Console →
Website →
Asking an AI? Paste this URL https://keelson.dev/llms.txt

External integrations

There are no restrictions on outbound traffic from an app to services such as OpenAI, Slack, Google Sheets, or an external database. Pass API keys as secrets and make the calls.

By default, all traffic coming into the app from an external source stops at the sign-in screen. You must declare callers that do not have a browser sign-in, such as a Stripe webhook or an API call from an internal system.

Declare paths that allow requests without sign-in under auth.endpoints in keelson.yaml. Paths must start with /api/webhooks/ or /api/external/.

auth:
endpoints:
- path: /api/webhooks/stripe
methods: [POST]

Requests to these paths do not receive X-Keelson-User-Id. The app must verify that the caller is legitimate.

  • When the sender provides a signature (Stripe, GitHub, Slack, and similar services): receive the signing secret through secrets and verify the signature
  • When the sender does not provide a signature: issue an app token (described below) and receive it in the X-Webhook-Secret header or in the URL /api/webhooks/<token>/...

Undeclared paths continue to require sign-in. Open only the paths required for webhooks.

The platform reserves /api/webhooks/email and /api/webhooks/email-events, so you cannot declare them as custom app webhooks. When inbound email is enabled, the platform calls /api/webhooks/email directly as described below; you do not add it to auth.endpoints.

Issue an app token when another internal system or script needs to call the app’s API.

Terminal window
keelson apps tokens create --name batch-client --scope api --allowed-ip 203.0.113.0/24
  • Tokens have the format keelson_... and are shown only once, when created
  • --scope api corresponds to paths under /api/external/, while --scope webhook corresponds to paths under /api/webhooks/
  • Use --allowed-ip to restrict source IPs (CIDR, multiple values supported). This restriction applies per token, separately from workspace or app IP controls
  • You can configure the same settings from the app screen in the console. Use keelson apps tokens list / rotate / delete to rotate and revoke tokens

The caller adds Authorization: Bearer keelson_... and calls a path declared in auth.endpoints.

Terminal window
curl -H "Authorization: Bearer keelson_xxx" https://acme--myapp.keelson.run/api/external/status
auth:
endpoints:
- path: /api/external/status
methods: [GET]
- /api/external/import # No methods specified = all methods

Before forwarding the request, the gateway checks in order that the token is valid, the scope matches, the token belongs to this app, the source IP is allowed, and the declared path and method match.

You can receive email addressed to your app through Keelson.

email:
inbound:
enabled: true

This is disabled by default. Do not configure it for apps that only send email or do not handle email.

After a successful deployment, the app receives the address <slug>@inbound.keelson.run, where <slug> is the app slug. The console shows the exact address assigned in the current environment.

Implement POST /api/webhooks/email in the app. This is a platform-reserved endpoint, so do not declare it under auth.endpoints; Keelson calls it directly when mail arrives.

The following JSON is an excerpt of one delivery, not the complete payload. Your handler should ignore additional keys.

{
"delivery_id": "del_01JEXAMPLE",
"attempt": 1,
"received_at": "2026-09-04T10:15:30Z",
"from": {
"name": "Ada Lovelace",
"address": "ada@example.com"
},
"to": [
{
"name": "Support",
"address": "my-app@inbound.keelson.run"
}
],
"cc": [],
"reply_to": {
"name": "Ada Lovelace",
"address": "replies@example.com"
},
"subject": "Question about my account",
"text": "Hello from the plain-text part.",
"html": "<p>Hello from the HTML part.</p>",
"envelope_to": "my-app@inbound.keelson.run",
"references": ["<earlier-message@example.com>"],
"attachments": [
{
"id": "att_01JEXAMPLE",
"filename": "question.pdf",
"content_type": "application/pdf",
"size_bytes": 48231,
"download_url": "https://example.invalid/temporary-download"
}
]
}

Keelson provides the signing secret to the app in KEELSON_EMAIL_WEBHOOK_SECRET. Always verify the signature against the unmodified request body before processing the message. Choose the example for your SDK; each endpoint returns 200 after successful verification and 401 when verification fails.

import os
from fastapi import FastAPI, HTTPException, Request, Response
from keelson_email import EmailError, verify_webhook
app = FastAPI()
secret = os.environ["KEELSON_EMAIL_WEBHOOK_SECRET"]
@app.post("/api/webhooks/email")
async def receive_email(request: Request) -> Response:
try:
message = verify_webhook(await request.body(), request.headers, secret)
except EmailError as exc:
raise HTTPException(status_code=401, detail="invalid signature") from exc
# Process message here.
return Response(status_code=200)
import { createServer } from "node:http";
import { verifyWebhook } from "@keelsonhq/email";
const secret = process.env.KEELSON_EMAIL_WEBHOOK_SECRET;
if (!secret) throw new Error("KEELSON_EMAIL_WEBHOOK_SECRET is required");
createServer(async (request, response) => {
if (request.method !== "POST" || request.url !== "/api/webhooks/email") {
response.writeHead(404).end();
return;
}
let message;
try {
message = await verifyWebhook(request, secret);
} catch {
response.writeHead(401).end();
return;
}
// Process message here.
response.writeHead(200).end();
}).listen(process.env.PORT ?? 3000);
package main
import (
"net/http"
"os"
"github.com/keelsonhq/go-sdk/email"
)
func main() {
secret := os.Getenv("KEELSON_EMAIL_WEBHOOK_SECRET")
if secret == "" {
panic("KEELSON_EMAIL_WEBHOOK_SECRET is required")
}
http.HandleFunc("/api/webhooks/email", func(w http.ResponseWriter, r *http.Request) {
message, err := email.VerifyWebhook(r, secret)
if err != nil {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
// Process message here.
_ = message
w.WriteHeader(http.StatusOK)
})
if err := http.ListenAndServe(":"+os.Getenv("PORT"), nil); err != nil {
panic(err)
}
}

To connect to PostgreSQL, MySQL, or your own libSQL database, set db.mode: none and pass the connection details as secrets. See Database for details.

Save files that exceed the Files / Media SDK limits directly from the app to an S3-compatible object store. Pass credentials as secrets.