Authenticating Requests with Per-Request Clients
This is the foundation everything else in the integration builds on, and it's the one thing
most integrations get wrong. This guide shows you how to extract the caller's API key, build a
per-request SDK client in seven frameworks, guard protected routes with 401, verify a key,
and keep rotation a non-event.
The rule, stated once and precisely:
The Sent API key is a per-request credential. It arrives as
Authorization: Bearer <key>, and you use it to build an SDK client for that request. It is never a boot-time singleton, and it is never a stored or required environment variable.
Do not do this. The most common mistake in a Sent integration:
// ❌ Anti-pattern: a global client built from an env var at boot.
import SentDm from "@sentdm/sentdm";
export const sent = new SentDm({ apiKey: process.env.SENT_DM_API_KEY });This bakes a single tenant's key into your process, forces a secret into your environment,
makes multi-tenant serving impossible, and turns key rotation into a redeploy. There is no
SENT_DM_API_KEY env var anywhere in this blueprint. Build the client per request
instead.
Quick path. If you just need this working: pick your language/framework tab under
The per-request client factory, copy the bearer-extraction
function and the 401 guard, and call the factory at the top of every protected route. That's
the whole pattern. The rest of this page covers key sources, 401 handling, verification,
and rotation; the reasoning behind the rule lives in
About per-request credentials.
The per-request client factory
The pattern is identical in every language: pull the bearer token off the request, reject with
401 if it's missing, and construct a client bound to that key. Nothing is cached across
requests; nothing reads a global.
import SentDm from "@sentdm/sentdm";
import type { Request } from "express";
import { ApiError } from "../types";
/** Builds SDK-backed services bound to a specific customer's API key. */
export function servicesForApiKey(apiKey: string): Services {
const client = new SentDm({ apiKey });
return { sentService: new SentService(client), /* … */ };
}
/**
* Resolve the API key for THIS request. The ONLY valid source is
* `Authorization: Bearer <key>`. The key is never persisted.
*/
export function apiKeyFromRequest(req: Request): string | undefined {
const header = req.header("authorization");
const bearer = header?.match(/^Bearer\s+(.+)$/i)?.[1]?.trim();
return bearer || undefined;
}
/** Per-request services — throws 401 when no bearer token is present. */
export function servicesForRequest(req: Request): Services {
const apiKey = apiKeyFromRequest(req);
if (!apiKey) {
throw new ApiError(401, "Unauthorized",
"Missing API key — send it as Authorization: Bearer <key>.");
}
return servicesForApiKey(apiKey);
}import SentDm from "@sentdm/sentdm";
import type { NextRequest } from "next/server";
export function apiKeyFromRequest(request: NextRequest | Request): string | undefined {
const header = request.headers.get("authorization");
const bearer = header?.match(/^Bearer\s+(.+)$/i)?.[1]?.trim();
return bearer || undefined;
}
export class MissingApiKeyError extends Error {
readonly status = 401;
readonly code = "Unauthorized";
constructor() {
super("Missing API key — send it as Authorization: Bearer <key>.");
}
}
/** Builds an SDK client bound to a specific customer's key. */
export function clientForApiKey(apiKey: string): SentDm {
return new SentDm({ apiKey, maxRetries: 2, timeout: 30_000 });
}
/** Per-request client — throws MissingApiKeyError (401) when absent. */
export function clientForRequest(request: NextRequest | Request): SentDm {
const apiKey = apiKeyFromRequest(request);
if (!apiKey) throw new MissingApiKeyError();
return clientForApiKey(apiKey);
}from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sent_dm import AsyncSent
security = HTTPBearer(auto_error=False)
async def get_optional_token(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
) -> str | None:
return credentials.credentials if credentials else None
def _resolve_api_key(token: str | None) -> str:
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing API key — send it as Authorization: Bearer <key>.",
)
return token
def get_sent_client(token: str | None = Depends(get_optional_token)) -> AsyncSent:
"""Per-request SDK client bound to the customer's API key."""
api_key = _resolve_api_key(token)
return AsyncSent(api_key=api_key)
def get_sent_service(client: AsyncSent = Depends(get_sent_client)) -> SentService:
return SentService(client)// ClientResolver builds a per-request SentClient from the caller's API key.
// The key lives only for the duration of the request. No env fallback.
type ClientResolver struct{ logger *zap.Logger }
var bearerRE = regexp.MustCompile(`(?i)^Bearer\s+(.+)$`)
var ErrMissingAPIKey = &models.HTTPError{
Code: http.StatusUnauthorized, ErrorCode: "UNAUTHORIZED",
Message: "Missing API key — send it as Authorization: Bearer <key>.",
}
func (r *ClientResolver) APIKeyFromRequest(c echo.Context) string {
header := c.Request().Header.Get("Authorization")
if m := bearerRE.FindStringSubmatch(strings.TrimSpace(header)); m != nil {
if key := strings.TrimSpace(m[1]); key != "" {
return key
}
}
return ""
}
// ForRequest → SentClient bound to this request's key, or 401 when absent.
func (r *ClientResolver) ForRequest(c echo.Context) (*SentClient, error) {
apiKey := r.APIKeyFromRequest(c)
if apiKey == "" {
return nil, ErrMissingAPIKey
}
return NewSentClient(apiKey, r.logger), nil
}
func NewSentClient(apiKey string, logger *zap.Logger) *SentClient {
return &SentClient{client: sentdm.NewClient(option.WithAPIKey(apiKey)), logger: logger}
}<?php
namespace App\Services;
use Illuminate\Http\Request;
use SentDm\Client;
/**
* Builds the Sent SDK client for THIS request. Call this directly from a
* controller or a request-scoped service — do NOT bind it in a
* ServiceProvider with `$this->app->singleton(...)`.
*/
class SentClientFactory
{
public function forRequest(Request $request): Client
{
$apiKey = $this->resolveApiKey($request);
if (empty($apiKey)) {
abort(401, 'Missing API key — send it as Authorization: Bearer <key>.');
}
return new Client(apiKey: $apiKey, requestOptions: ['maxRetries' => 2]);
}
private function resolveApiKey(Request $request): ?string
{
$header = (string) $request->header('Authorization', '');
if (preg_match('/^Bearer\s+(.+)$/i', $header, $m)) {
$bearer = trim($m[1]);
return $bearer !== '' ? $bearer : null;
}
return null;
}
}Why not the container? $this->app->singleton(Client::class, fn () => new Client(apiKey: config('services.sent.key'))) is the same boot-time-singleton anti-pattern from the top of
this page, expressed with Laravel's container. Under standard PHP-FPM it happens to be
harmless because the whole process (and the container with it) is thrown away after each
request. But under Laravel Octane,
which reuses the same process across many requests exactly like Node or Python, that
singleton would freeze the first request's key into every request the worker ever serves
after it. Resolve SentClientFactory fresh (app()->bind(...) at most, never singleton)
and call forRequest($request) explicitly. Don't let the container hold the client itself.
/**
* Builds the Sent SDK client for THIS request. The API key is a per-customer
* RUNTIME value, never required at boot and never persisted. No env fallback.
*/
class SentClientFactory
{
public function __construct(
private readonly RequestStack $requestStack,
private readonly int $maxRetries = 2,
) {}
public function create(): Client
{
$apiKey = $this->resolveApiKey();
if (empty($apiKey)) {
throw new HttpException(401,
'Missing API key — send it as Authorization: Bearer <key>.');
}
return new Client(apiKey: $apiKey, requestOptions: ['maxRetries' => $this->maxRetries]);
}
private function resolveApiKey(): ?string
{
$request = $this->requestStack->getCurrentRequest();
$header = (string) ($request?->headers->get('Authorization') ?? '');
if (preg_match('/^Bearer\s+(.+)$/i', $header, $m)) {
$bearer = trim($m[1]);
return $bearer !== '' ? $bearer : null;
}
return null;
}
}class ApplicationController < ActionController::API
private
# Resolve the customer API key for THIS request. nil when no bearer token.
def current_api_key
header = request.headers["Authorization"]
header&.match(/\ABearer\s+(.+)\z/i)&.captures&.first&.strip.presence
end
# Guard for protected /api/* routes. 401 when no bearer token is present.
def require_api_key!
return if current_api_key.present?
render json: {
error: { code: "UNAUTHORIZED",
message: "Missing API key — send it as Authorization: Bearer <key>." }
}, status: :unauthorized
end
# Per-request SDK client built from the resolved key.
def sent_client
Sentdm::Client.new(api_key: current_api_key)
end
endNote the framework-idiomatic shapes: Express uses a plain factory function, FastAPI a
Depends chain, Go a ClientResolver, Laravel a plain service class called explicitly (never
container-bound, see the preceding warning), Symfony a DI-injected factory scoped to
RequestStack, Rails a controller before_action guard. They all implement the same
contract: build fresh from the request, never cache across one.
One regex, everywhere. Every backend matches ^Bearer\s+(.+)$ case-insensitively,
trims, and treats empty as absent. Keep that parsing in the factory so no controller
re-implements it.
Where the key comes from
The factory needs a key on every request; its source depends on your deployment shape:
- Multi-tenant. Each customer supplies their own key as
Authorization: Bearer <key>; extract it exactly as the factories in the preceding section do. Never share a client across tenants. - Single-tenant. Your own backend resolves the one key inside each request or job (from your config or secrets manager, never cached into a module-level global at boot) and hands it to the same factory. Never ship the key to a browser or mobile app.
For why the pattern works this way, and what it buys you in rotation and tenant isolation, see About per-request credentials.
No key → 401
A protected route with no bearer token must fail closed with 401, before any SDK call. Every
factory in The per-request client factory does exactly
this. Two things to keep straight:
- Unprotected routes never gate on it.
GET /healthand the webhook receiverPOST /webhooks/sentdon't build an SDK client, so they never call the factory and never return401. (The receiver has its own auth: the signature.) - Fail before the network. Reject the missing key locally; don't send an unauthenticated
request to Sent just to bounce a
401back.
Verifying a key
Before trusting a key, or to let a user confirm the one they pasted works, validate it
against GET /v3/me. This is the canonical POST /api/auth/verify endpoint: it makes one
authenticated call and reports whether the key is good, without leaking upstream error shapes.
Two deliberate departures from the rest of this page: the route reads the key from the request
body because its whole job is to test a key the user just pasted (it's not yet a trusted
credential), and the upstream call sends the key in the x-api-key header because it calls
the Sent REST API directly. The Authorization: Bearer shape is your API's contract with its
own callers, while Sent's API authenticates with x-api-key.
In the TypeScript sample, asyncHandler is one of the
shared helpers defined in project setup.
router.post("/verify", asyncHandler(async (req, res) => {
const { apiKey } = req.body as { apiKey?: string };
if (!apiKey) {
return res.status(400).json({ valid: false, error: { status: 400, code: "MissingApiKey", message: "apiKey is required" } });
}
const baseUrl = process.env["SENT_BASE_URL"] ?? "https://api.sent.dm";
const response = await fetch(`${baseUrl}/v3/me`, {
headers: { "x-api-key": apiKey, "Content-Type": "application/json" },
});
const data = await response.json();
if (response.ok) return res.status(200).json({ valid: true, account: data });
return res.status(200).json({
valid: false,
error: { status: response.status, code: data.code ?? "ApiError",
message: data.message ?? "Authentication failed" },
});
}));@router.post("/api/auth/verify")
async def verify(body: dict):
api_key = body.get("apiKey")
if not api_key:
return {"valid": False, "error": {"status": 400, "code": "MissingApiKey", "message": "apiKey is required"}}
base_url = os.getenv("SENT_BASE_URL", "https://api.sent.dm")
async with httpx.AsyncClient() as http:
resp = await http.get(f"{base_url}/v3/me",
headers={"x-api-key": api_key})
data = resp.json()
if resp.is_success:
return {"valid": True, "account": data}
return {"valid": False, "error": {"status": resp.status_code,
"code": data.get("code", "ApiError"),
"message": data.get("message", "Authentication failed")}}verify returns 200 with { valid: false, error } for a bad key rather than propagating
the upstream 401. That's a deliberate UX choice for a "test your key" screen: the request
to your endpoint succeeded; the key is what's invalid.
Secret hygiene and rotation
The key is a bearer credential: anyone holding it can send on the account. Treat it accordingly.
- Never log it. Not in request logs, not in error payloads, not in traces. Redact the
Authorizationheader at your logging boundary. - Never persist it. Keep it out of environment variables, config files, database columns, and disk. It exists only in the request scope. When the request ends, so does the key. The one narrow, deliberate exception is queued/async work, where the key necessarily outlives the original request. See Errors & resilience for the hygiene that exception requires.
- Transport only over TLS. The bearer token is only as safe as the channel, so require HTTPS end to end.
- Scope who can send it. In your own front end, the key comes from the authenticated user's session or vault, attached per request, never hard-coded or shipped to the browser.
- Rotation is a non-event. Because nothing caches the key, a customer rotates in the dashboard and sends the new key on the next request. Your integration needs no redeploy, restart, or coordinated cutover. This is the direct reward for not holding the key in a singleton.
If rotating a key would require you to redeploy or restart, that's the singleton anti-pattern leaking back in. In this architecture, rotation should require zero changes to your running app.
Next steps
You can now build an authenticated client for any request. Put it to work on the outbound path.
Sending messages
The outbound path: a thin service layer over messages.send, request shaping, and mapping the response to your contract.
Setting up the project
Back up a step: prerequisites, installing the SDK, and config that fails fast.
About per-request credentials
The reasoning behind the rule: who sends the bearer key, and why tenant isolation is structural.
Setting Up the Project: SDK, Config, and Folder Layout
Prerequisites, SDK installation, the SDK-vs-raw-REST decision, config validation that fails fast, and a folder layout that realizes the reference architecture.
About Per-Request Credentials
Why the Sent API key is a per-request credential, never a boot-time singleton. Who sends the bearer key in each setup, and why isolation is structural.