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.
Receive a webhook
Section titled “Receive a webhook”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
secretsand verify the signature - When the sender does not provide a signature: issue an app token (described below) and receive it in the
X-Webhook-Secretheader 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.
Receive calls with an API token
Section titled “Receive calls with an API token”Issue an app token when another internal system or script needs to call the app’s API.
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 apicorresponds to paths under/api/external/, while--scope webhookcorresponds to paths under/api/webhooks/- Use
--allowed-ipto 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 / deleteto rotate and revoke tokens
The caller adds Authorization: Bearer keelson_... and calls a path declared in auth.endpoints.
curl -H "Authorization: Bearer keelson_xxx" https://acme--myapp.keelson.run/api/external/statusauth: endpoints: - path: /api/external/status methods: [GET] - /api/external/import # No methods specified = all methodsBefore 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.
Receive email
Section titled “Receive email”You can receive email addressed to your app through Keelson.
email: inbound: enabled: trueThis 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" } ]}Verify the inbound email signature
Section titled “Verify the inbound email signature”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.
Python
Section titled “Python”import os
from fastapi import FastAPI, HTTPException, Request, Responsefrom 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)Node.js
Section titled “Node.js”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) }}External databases
Section titled “External databases”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.
External object storage
Section titled “External object storage”Save files that exceed the Files / Media SDK limits directly from the app to an S3-compatible object store. Pass credentials as secrets.