HTTP Utilities

Svelar provides two HTTP layers:

  • Client-side (apiFetch) — CSRF-aware fetch for calling your own API from Svelte components
  • Server-side (Http) — Fluent HTTP client for calling third-party APIs (Postmark, Stripe, Mailchimp, etc.)

Server-Side HTTP Client

The Http client is designed for server-to-server communication — calling external APIs from your controllers, services, and jobs. It provides a fluent, immutable API inspired by Laravel's Http facade.

Import

import { Http } from '@beeblock/svelar/http';

Basic Usage

// GET request
const response = await Http
  .baseUrl('https://api.example.com')
  .withToken(process.env.API_TOKEN!)
  .get('/users');

console.log(response.data);   // parsed JSON
console.log(response.status); // 200
console.log(response.ok);     // true

// POST with JSON body
const response = await Http
  .withToken(process.env.STRIPE_SECRET!)
  .baseUrl('https://api.stripe.com/v1')
  .contentType('application/x-www-form-urlencoded')
  .post('/customers', 'email=user@example.com&name=John');

Authentication

// Bearer token (default)
Http.withToken('sk_live_...')

// Custom header token (e.g., Postmark uses X-Postmark-Server-Token)
Http.withHeaders({ 'X-Postmark-Server-Token': process.env.POSTMARK_TOKEN! })

// Basic auth
Http.withBasicAuth('username', 'password')

// API key header
Http.withHeaders({ 'X-API-Key': process.env.MAILCHIMP_KEY! })

Third-Party API Examples

Postmark

import { Http } from '@beeblock/svelar/http';

const postmark = Http
  .baseUrl('https://api.postmarkapp.com')
  .withHeaders({ 'X-Postmark-Server-Token': process.env.POSTMARK_TOKEN! });

const response = await postmark.post('/email', {
  From: 'hello@myapp.com',
  To: 'user@example.com',
  Subject: 'Welcome!',
  HtmlBody: '<h1>Hello</h1>',
});

Resend

const resend = Http
  .baseUrl('https://api.resend.com')
  .withToken(process.env.RESEND_API_KEY!);

await resend.post('/emails', {
  from: 'hello@myapp.com',
  to: 'user@example.com',
  subject: 'Welcome!',
  html: '<h1>Hello</h1>',
});

Stripe

const stripe = Http
  .baseUrl('https://api.stripe.com/v1')
  .withToken(process.env.STRIPE_SECRET!)
  .contentType('application/x-www-form-urlencoded');

// Create a customer
const customer = await stripe.post('/customers', 'email=user@example.com');

// List charges with query params
const charges = await stripe.query({ limit: 10 }).get('/charges');

Mailchimp

const mailchimp = Http
  .baseUrl(`https://${process.env.MAILCHIMP_DC}.api.mailchimp.com/3.0`)
  .withToken(process.env.MAILCHIMP_KEY!);

// Get lists
const lists = await mailchimp.get('/lists');

// Add a subscriber
await mailchimp.post(`/lists/${listId}/members`, {
  email_address: 'user@example.com',
  status: 'subscribed',
});

Any REST API

const github = Http
  .baseUrl('https://api.github.com')
  .withToken(process.env.GITHUB_TOKEN!)
  .withHeaders({ 'X-GitHub-Api-Version': '2022-11-28' });

const repos = await github.get('/user/repos');

Retry, Idempotency & Resilience

// Retry up to 3 times on transient errors
const response = await Http
  .withToken(token)
  .baseUrl('https://api.example.com')
  .retry(3, 1000)
  .timeout(10_000)
  .get('/data');

By default, retries cover common transient statuses: 408, 409, 425, 429, 500, 502, 503, and 504, plus network errors and timeouts. Client errors such as 400, 401, 403, 404, and 422 fail immediately unless you explicitly include them in the retry policy.

Use the object form for production integrations that need backoff, jitter, Retry-After, or provider-specific classification:

const response = await Http
  .baseUrl('https://api.example.com')
  .withToken(token)
  .retry({
    times: 5,
    delay: 250,
    maxDelay: 10_000,
    backoff: 'exponential',
    jitter: 'full',
    statuses: [429, 500, 502, 503, 504],
    methods: ['GET', 'POST'],
    respectRetryAfter: true,
    when: ({ status }) => status !== 501,
  })
  .get('/reports');

With respectRetryAfter: true, a valid seconds/date header is a minimum wait, not a backoff value to shorten. If it exceeds maxDelay (30 seconds by default), the client stops retrying and throws the original HttpRequestError with its status, headers and body. Increase the wait budget to permit a longer server-directed pause; explicitly disabling respectRetryAfter opts out of that policy. This follows the wait semantics of RFC 9110.

For mutating third-party requests that may be retried, send an idempotency key supported by the provider:

await Http
  .baseUrl('https://api.stripe.com/v1')
  .withToken(process.env.STRIPE_SECRET!)
  .withIdempotencyKey(order.public_id)
  .post('/payment_intents', body);

Circuit breakers prevent repeated calls to an integration that is already failing:

const client = Http
  .baseUrl('https://api.example.com')
  .circuitBreaker({
    key: 'billing-api',
    failureThreshold: 5,
    cooldown: 30_000,
  });

The circuit is process-local and is meant as a fast safety primitive. Distributed credential/account quotas use the budget API below; a distributed circuit can be composed with cache coordination when needed.

Distributed Credential Budgets

import { Cache } from '@beeblock/svelar/cache';
import { Http } from '@beeblock/svelar/http';

const client = Http.baseUrl(integrationUrl).budget({
  store: Cache.store('redis'),
  namespace: `billing:${team.id}`,
  limits: [
    { scope: 'credential', key: credential.public_id, limit: 100, window: 60 },
    { scope: 'account', key: account.public_id, limit: 1000, window: 60 },
  ],
});
const response = await client.get('/records');

Every actual transport attempt, including retries, reserves all applicable limits atomically after request hooks. There are no refunds: failed upstream requests can consume provider quota too. Identifiers are hashed into stable namespaced keys, with a shared Redis Cluster hash slot. Supply application-owned credential/account identities explicitly; the client does not infer them from secret token headers.

HttpBudgetExceededError reports quota exhaustion with a retry time. HttpBudgetUnavailableError reports failed coordination. Both fail closed before transport and do not trigger retries or open the circuit. Memory budgets are process-local; Redis budgets coordinate web/worker/scheduler and replicas. A configured budget requires an explicit capable cache store.

Hooks & Telemetry

Use hooks to add integration-level telemetry, credential budgets, or provider headers without scattering code across services:

import { Http, redactHttpHeaders, redactHttpPayload } from '@beeblock/svelar/http';
import { Log } from '@beeblock/svelar/logging';

const client = Http
  .baseUrl('https://api.example.com')
  .beforeRequest((request) => {
    request.headers['X-Request-Source'] = 'svelar';
  })
  .afterResponse(({ request, response, data }) => {
    Log.info('External HTTP request completed', {
      method: request.method,
      url: request.url,
      status: response.status,
      headers: redactHttpHeaders(request.headers),
      body: redactHttpPayload(data),
    });
  })
  .onError(({ request, error }) => {
    Log.warning('External HTTP request failed', {
      method: request.method,
      url: request.url,
      headers: redactHttpHeaders(request.headers),
      error: error instanceof Error ? error.message : String(error),
    });
  });

Error Handling

Failed requests throw HttpRequestError with the status code and response body:

import { Http, HttpRequestError } from '@beeblock/svelar/http';

try {
  await Http.withToken(token).baseUrl('https://api.stripe.com/v1').get('/charges');
} catch (err) {
  if (err instanceof HttpRequestError) {
    console.log(err.status); // 401
    console.log(err.body);   // { error: { message: 'Invalid API Key' } }
  }
}

Reusable Clients

Create a pre-configured client in a service file and reuse it across your app:

// src/lib/services/PostmarkClient.ts
import { Http } from '@beeblock/svelar/http';

export const postmarkClient = Http
  .baseUrl('https://api.postmarkapp.com')
  .withHeaders({ 'X-Postmark-Server-Token': process.env.POSTMARK_TOKEN! })
  .retry(2);

// src/lib/services/StripeClient.ts
import { Http } from '@beeblock/svelar/http';

export const stripeClient = Http
  .baseUrl('https://api.stripe.com/v1')
  .withToken(process.env.STRIPE_SECRET!)
  .contentType('application/x-www-form-urlencoded')
  .retry(2);
// In a controller or service
import { postmarkClient } from '$lib/services/PostmarkClient.js';

await postmarkClient.post('/email', {
  From: 'hello@myapp.com',
  To: user.email,
  Subject: 'Your invoice',
  HtmlBody: invoiceHtml,
});

Full API

Method Description
Http.baseUrl(url) Set the base URL for all requests
Http.withToken(token, type?) Set Authorization: Bearer <token> (or custom type)
Http.withBasicAuth(user, pass) Set Authorization: Basic <base64>
Http.withHeaders(headers) Merge additional headers
Http.accept(type) Set Accept header
Http.contentType(type) Set Content-Type header
Http.timeout(ms) Set request timeout (default: 30s)
Http.retry(times, delayMs?) Retry transient errors with linear delay
Http.retry(options) Configure status/method retry policy, exponential backoff, full jitter, Retry-After, and custom classifiers
Http.withIdempotencyKey(key?) Add an Idempotency-Key header to mutation requests
Http.circuitBreaker(options?) Enable a process-local circuit breaker for failing integrations
Http.beforeRequest(fn) Register a request hook
Http.budget(config) Atomically reserve distributed credential/account quota for each transport attempt
Http.afterResponse(fn) Register a response hook
Http.onError(fn) Register an error hook
Http.query(params) Add URL query parameters
Http.get(path) Send GET request
Http.post(path, body?) Send POST request
Http.put(path, body?) Send PUT request
Http.patch(path, body?) Send PATCH request
Http.delete(path, body?) Send DELETE request

All chainable methods return a new HttpClient instance (immutable) — safe to store and reuse.


Creating Custom Integrations

Svelar provides three levels of extensibility for third-party integrations:

Create a service class that wraps Http for a specific API. This is the simplest and most common pattern:

// src/lib/modules/billing/application/services/StripeService.ts
import { Http, HttpClient, HttpRequestError } from '@beeblock/svelar/http';

export class StripeService {
  private client: HttpClient;

  constructor() {
    this.client = Http
      .baseUrl('https://api.stripe.com/v1')
      .withToken(process.env.STRIPE_SECRET!)
      .contentType('application/x-www-form-urlencoded')
      .retry(2);
  }

  async createCustomer(email: string, name?: string) {
    const params = new URLSearchParams({ email });
    if (name) params.set('name', name);
    return this.client.post('/customers', params.toString());
  }

  async createSubscription(customerId: string, priceId: string) {
    const params = new URLSearchParams({
      customer: customerId,
      'items[0][price]': priceId,
    });
    return this.client.post('/subscriptions', params.toString());
  }

  async getInvoices(customerId: string) {
    return this.client.query({ customer: customerId }).get('/invoices');
  }
}

Use it in your controllers:

// src/lib/modules/billing/interface/http/controllers/BillingController.ts
import { Controller } from '@beeblock/svelar/routing';
import { StripeService } from '../../../application/services/StripeService.js';

export class BillingController extends Controller {
  private stripe = new StripeService();

  async subscribe(event) {
    const { priceId } = await event.request.json();
    const user = event.locals.user;

    const customer = await this.stripe.createCustomer(user.email, user.name);
    const subscription = await this.stripe.createSubscription(customer.data.id, priceId);

    return this.json({ subscription: subscription.data });
  }
}

2. Custom Mail Driver

To add a new email provider (e.g., Mailchimp Transactional), implement the MailTransport interface:

// src/lib/shared/mail/MailchimpTransport.ts
import { Http } from '@beeblock/svelar/http';
import type { MailTransport, MailMessage, SendResult } from '@beeblock/svelar/mail';

export class MailchimpTransport implements MailTransport {
  private apiKey: string;

  constructor(apiKey: string) {
    this.apiKey = apiKey;
  }

  async send(message: MailMessage): Promise<SendResult> {
    try {
      const response = await Http
        .baseUrl('https://mandrillapp.com/api/1.0')
        .post('/messages/send', {
          key: this.apiKey,
          message: {
            from_email: message.from?.address ?? message.from,
            to: [{ email: message.to, type: 'to' }],
            subject: message.subject,
            html: message.html,
            text: message.text,
          },
        });

      return { success: true, messageId: response.data[0]?._id };
    } catch (err: any) {
      return { success: false, error: err.message };
    }
  }
}

Register it in your app bootstrap:

// src/app.ts
import { Mailer } from '@beeblock/svelar/mail';
import { MailchimpTransport } from '$lib/shared/mail/MailchimpTransport.js';

Mailer.configure({
  default: 'mailchimp',
  mailers: {
    mailchimp: {
      driver: 'custom',
      transport: new MailchimpTransport(process.env.MAILCHIMP_API_KEY!),
    },
  },
});

3. Plugin (For Reusable, Publishable Integrations)

For integrations you want to distribute as an npm package, use the Plugin system:

// svelar-mailchimp/src/index.ts
import { Plugin } from '@beeblock/svelar/plugins';
import { MailchimpTransport } from './MailchimpTransport.js';

export class MailchimpPlugin extends Plugin {
  name = 'svelar-mailchimp';
  version = '1.0.0';
  description = 'Mailchimp Transactional integration for Svelar';

  register() {
    this.app.singleton('mailchimp', () => {
      return new MailchimpTransport(process.env.MAILCHIMP_API_KEY!);
    });
  }

  config() {
    return {
      key: 'mailchimp',
      defaults: {
        apiKey: '',
        defaultFromEmail: 'hello@example.com',
      },
    };
  }
}

Install and use:

// src/app.ts
import { PluginManager } from '@beeblock/svelar/plugins';
import { MailchimpPlugin } from 'svelar-mailchimp';

const plugins = new PluginManager(app);
plugins.use(new MailchimpPlugin());
await plugins.boot();

When to Use What

Pattern When to use
Service App-specific integrations (your Stripe, your CRM, your analytics)
Custom Driver Swappable implementations (new mail provider, new cache backend)
Plugin Publishable packages with migrations, config, and lifecycle hooks

Client-Side: apiFetch

The apiFetch function is a drop-in replacement for fetch that automatically handles CSRF tokens. It reads the XSRF-TOKEN cookie and attaches it as an X-CSRF-Token header on same-origin mutation requests (POST, PUT, PATCH, DELETE). It never automatically forwards a browser CSRF credential to another origin, and malformed cookies are treated as missing tokens rather than breaking the UI.

apiFetch and apiFetchJson read error messages and field errors from resource-style { data: null, meta: { message, errors } } responses. They also accept upstream/SvelteKit's top-level error fields. Use showToast: false when Superforms displays validation inline; do not duplicate a field error with a success or error toast. Successful JSON responses remain intact, including both data and meta; the client helper does not silently discard resource metadata.

Import

import { apiFetch } from '@beeblock/svelar/http';

Basic Usage

// GET request (no CSRF token needed)
const res = await apiFetch('/api/posts');
const posts = await res.json();

// POST request (CSRF token auto-attached)
const res = await apiFetch('/api/posts', {
  method: 'POST',
  body: JSON.stringify({ title: 'Hello', body: 'World' }),
});

// PUT request
await apiFetch(`/api/posts/${id}`, {
  method: 'PUT',
  body: JSON.stringify({ title: 'Updated' }),
});

// DELETE request
await apiFetch(`/api/posts/${id}`, { method: 'DELETE' });

In Svelte Components

<script lang="ts">
  import { apiFetch } from '@beeblock/svelar/http';

  let posts: any[] = $state([]);
  let loading = $state(false);

  async function loadPosts() {
    loading = true;
    const res = await apiFetch('/api/posts');
    posts = await res.json();
    loading = false;
  }

  async function createPost(title: string, body: string) {
    const res = await apiFetch('/api/posts', {
      method: 'POST',
      body: JSON.stringify({ title, body }),
    });

    if (res.ok) {
      await loadPosts(); // Refresh
    }
  }
</script>

Custom CSRF Configuration

// Custom cookie/header names
await apiFetch('/api/data', {
  method: 'POST',
  body: JSON.stringify({ key: 'value' }),
  csrfCookieName: 'MY_CSRF_TOKEN',
  csrfHeaderName: 'X-My-Csrf',
});

How It Works

Svelar's CsrfMiddleware sets an XSRF-TOKEN cookie on every safe request (GET, HEAD, OPTIONS). When apiFetch makes a mutation request, it reads that cookie and sends it back as a header. The middleware validates the header matches the cookie — this is the "double-submit cookie" pattern.

Safe methods (GET, HEAD, OPTIONS) skip CSRF entirely. If the body is a string and no Content-Type is set, apiFetch defaults to application/json.

buildUrl

Helper to construct URLs with query parameters:

import { buildUrl } from '@beeblock/svelar/http';

buildUrl('/api/posts', { page: 1, per_page: 10 });
// => '/api/posts?page=1&per_page=10'

buildUrl('/api/users', { role: 'admin', active: true });
// => '/api/users?role=admin&active=true'

// Null/undefined values are omitted
buildUrl('/api/posts', { page: 1, search: undefined });
// => '/api/posts?page=1'

getCsrfToken

Extract the CSRF token directly:

import { getCsrfToken } from '@beeblock/svelar/http';

const token = getCsrfToken();
// Returns null on the server (SSR)

Next Steps

  • Set up Internationalization for multi-language support
  • Explore Forms for server-side validation with Superforms
  • Learn about Middleware to understand how CSRF protection works
  • See Mail for built-in email drivers (SMTP, Postmark, Resend)
  • Check Plugins for building publishable integrations

Svelar HTTP Guide © 2026

Svelar © 2026 · MIT License