Menu

Guides

View as Markdown

Endpoints & webhooks

Most browser writes should be mutation() forms: schema input, CSRF, no-JS behavior, replay, guards, transactions, query truth, and typed failure UI. Use raw ingress when the caller is not a browser form or when the protocol itself needs raw HTTP control: OAuth callbacks, third-party webhooks, downloads, health checks, adapter-owned mounts, and externally authenticated machine writes. CSV/TSV/spreadsheet exports also live here if an app needs them, but they are ordinary app-owned raw response code, not a Kovo safe-by-default helper lane.

endpoint() for raw HTTP#

An endpoint is registry-visible and receives the raw Request before body parsing:

ts
import { endpoint } from '@kovojs/server';

export const oauthCallback = endpoint('/auth/callback', {
  method: 'GET',
  reason: 'OAuth provider callback',
  auth: { kind: 'none', justification: 'OAuth state parameter validates callback' },
  csrf: false,
  csrfJustification: 'OAuth provider callback is not a browser form submission',
  response: { appOwnedSafety: true, body: 'redirect', cache: 'no-store' },
  handler: finishOAuth,
});

Exact and prefix mounts are declared. Cookies are not interpreted into ambient req.session, and an endpoint does not get the mutation lifecycle unless you build it deliberately. A CSRF exemption is sound here because auth does not ride browser ambient authority; verifier tokens, signatures, OAuth state, or another explicit scheme authenticate the request. The handler can still parse the raw request when the protocol needs it.

webhook() for provider POSTs#

Use webhook() for third-party POSTs that write Kovo-owned data:

ts
import { hmacSignature } from '@kovojs/core/webhooks';
import { domain, s } from '@kovojs/server';
import { webhook, webhookReplayIdentity } from '@kovojs/server/webhooks';

const order = domain('order');
declare const providerWebhookReplayStore: any;
declare const applyOrderEvent: any;

export const orderWebhook = webhook('/hooks/order-provider', {
  verify: hmacSignature({
    encoding: 'hex',
    header: 'x-provider-signature',
    payload: (request) => request.payload,
    secret: process.env.PROVIDER_WEBHOOK_SECRET!,
    tolerance: { header: 'x-provider-timestamp', seconds: 5 * 60 },
  }),
  input: s.object({
    id: s.string(),
    occurredAtMs: s.number().int(),
    type: s.string(),
  }),
  idempotency: (event) => webhookReplayIdentity(event.id, event.occurredAtMs),
  replayStore: providerWebhookReplayStore,
  writes: [order],
  async handler(event, context) {
    await context
      .declareSystemWrite('Provider webhook applies provider-confirmed order events')
      .runMutation(applyOrderEvent, event);

    context.recordChange(order, { keys: [event.id] });
  },
});

The path is explicit because it is the provider-facing address. The webhook registry identity is derived from orderWebhook and its module path, so replay and audit names follow the source instead of duplicating a string.

Replace applyOrderEvent with your app mutation or write helper. A webhook that can write Kovo-owned data must declare both idempotency and replayStore, then choose an explicit actAs(...) or declareSystemWrite(...) posture before composing through mutations or managed DB work.

Use the provider event's own signed occurrence timestamp for occurredAtMs. Do not use Date.now() or the signature delivery timestamp. Kovo accepts up to five minutes of future clock skew and keeps replay truth for 30 days from that authenticated occurrence. Stale or farther-future events return 422 before the replay store or handler runs.

The lifecycle is fixed: capture raw bytes, verify, parse/coerce a loose input schema, reserve replay by the opaque provider event identity, run the handler with tx, commit, emit every recordChange() as the unified change record, and return the provider-appropriate 2xx. A redelivered event inside the horizon replays the stored response and does not re-execute the handler. The writes list is the static audit fact; the recordChange() calls are the runtime key-level records.

The verifier kit includes generic HMAC and Standard Webhooks helpers. Provider-specific recipes, including Stripe's exact signature format, can live in app/example code on top of those helpers; the audit prints the resolved verifier scheme or a named custom/none justification.

Set response metadata#

Use the named file options for fields that change how a response is interpreted. The direct header bag is only for cache metadata:

ts
import { respond } from '@kovojs/server';

declare const pdfBytes: Uint8Array;

function downloadInvoice() {
  return respond.file(pdfBytes, {
    contentType: 'application/pdf',
    etag: '"invoice-42"',
    filename: 'invoice-42.pdf',
    headers: {
      'Cache-Control': 'private, no-store',
      Vary: 'Cookie',
    },
  });
}

respond.file() and respond.stream() accept Cache-Control, Last-Modified, and Vary in headers. Use contentType, etag, and filename/disposition for the dedicated fields. Return redirect() for a location. Mutation cookies go through context.setCookie().

Raw endpoint() responses may need other end-to-end integration headers. Return a Web Response for those. Kovo still rejects Content-Length, Connection, Transfer-Encoding, and the other adapter-owned framing or hop-by-hop fields before anything reaches the socket. Header controls such as CR, LF, and NUL also fail closed.

Send CSRF tokens to raw endpoints#

Default-CSRF unsafe endpoints accept the same token carriers Kovo can parse before the handler runs: form fields and JSON bodies. For the first anonymous page, use mintCsrfField or mintCsrfToken so the response can also set Kovo's anonymous CSRF cookie. The ordinary browser path is a route plus a mutation() form, where Kovo owns this lifecycle automatically. A raw bootstrap is an advanced integration: its endpoint must execute a verifier (or be a framework-owned self-verifying adapter) before it can emit browser state.

Call the helper inside that verified endpoint while it constructs its Response. When the endpoint runs through createRequestHandler(), Kovo privately captures a first-anonymous binding cookie and attaches it during final response reconstruction. Do not copy it into app-owned headers yourself: an explicit Set-Cookie is still app-authored browser state and is rejected without the endpoint's browser-state authorization proof.

ts
import { mintCsrfField } from '@kovojs/server/security';

export async function renderUploadForm(request: Request) {
  const csrf = mintCsrfField(request, {
    ...appCsrf,
    audience: 'endpoint:/files',
  });

  return new Response(
    `<form method="post" action="/files">${csrf.html}<button>Upload</button></form>`,
  );
}

Call renderUploadForm(request) only from the verified raw endpoint handler. The public mint helpers do not accept a mutation target. Route mutation forms use typed <form mutation={definition}> authoring so Kovo emits CSRF and Kovo-Idem together. The returned setCookie remains available for non-Kovo response integrations; explicitly attaching its exact bytes to a managed raw response is deduplicated, but it still requires the same endpoint browser-state proof and reserved-header posture as any app-authored Set-Cookie.

For verified JSON bootstraps, send mintCsrfToken(request, appCsrf, { audience: 'endpoint:/bootstrap' }).token as kovo-csrf; the managed finalizer delivers a first binding cookie. A detached call can still use a session or an anonymous cookie already on the request. It cannot create the first anonymous binding: Kovo rejects that call because there is no active response lifecycle proving the new cookie can reach the browser. Direct runEndpoint() execution has no managed cookie sink and likewise rejects a first anonymous mint. If your protocol needs text/plain, bytes, or a signed raw body, use csrf: false with a verifier, OAuth state, or another non-browser auth scheme and name the justification. Browser credential forms should stay as mutation() forms so Kovo owns the CSRF field, no-JS response, replay, and typed failure UI.

Audit the ingress surface#

kovo explain endpoints is the stable security-review table. It lists every declared endpoint and webhook, every mutation, and every route returning respond.file() or respond.stream():

kovo-explain/v1
ENDPOINTS
ENDPOINT app-shell/order-paid surface=webhook method=POST path=/webhooks/order-paid mount=exact auth=verifier:stripe-signature csrf=exempt:signed stripe webhook cache=no-store body=raw bodySize=- rateLimit=webhook:stripe headers=Stripe-Signature files=- dynamic=- writes=order
ENDPOINT echo surface=endpoint method=POST path=/api/echo-json mount=exact auth=public:public echo endpoint is CSRF checked csrf=checked cache=no-store body=json bodySize=- rateLimit=- headers=- files=- dynamic=- writes=-
ENDPOINT health surface=endpoint method=GET path=/healthz mount=exact auth=none:public uptime probe csrf=safe:read-only cache=no-store body=json bodySize=- rateLimit=- headers=- files=- dynamic=- writes=-
ENDPOINT inventory/download surface=route-file method=GET path=/downloads/inventory.bin mount=exact auth=custom:api-key csrf=safe:read-only cache=private,no-store body=bytes bodySize=stream rateLimit=download:user headers=Content-Disposition,Content-Type files=inventory.bin dynamic=- writes=-
SUMMARY total=4

Each row is key/value text: surface, method, path, mount mode, auth scheme, CSRF posture, cache/body posture, response headers, file fields, dynamic-route posture, and the write-to-domain chain where one exists. A csrf: false mutation appears here too, and the CSRF/session gate guarantees that it does not read ambient session authority. If that mutation uses replay storage, declare machineReplayPrincipal from the post-verification request so one caller cannot replay another caller's response.

Handle failure#

Keep provider and machine failures explicit:

  • Verifier failures return the fixed unauthorized/bad-request response for that verifier before the handler runs.
  • context.fail(code, payload, { status, retryAfter }) is the expected provider-facing failure path when the request is valid but the event cannot be accepted.
  • Handler throws roll back the transaction and do not publish recordChange() output.
  • Replay conflicts reuse the stored response for the same provider event id instead of running the handler twice.

That posture keeps "bad signature", "valid but rejected event", and "operational failure" separate in logs and provider retries.

Next#

Spec & diagnostics

Raw endpoint(), webhook(), verifier kits, no ambient session, CSRF exemptions, response header channel, typed cookie builder, and KV415: SPEC §9.1. csrf: false mutation restrictions and KV418: SPEC §6.6. The kovo explain endpoints audit and printed row vocabulary: SPEC §11.4.

API reference: @kovojs/core, @kovojs/server.