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
end

Note 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 /health and the webhook receiver POST /webhooks/sent don't build an SDK client, so they never call the factory and never return 401. (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 401 back.

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 Authorization header 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.

On this page