Skip to content

API Access

Daniel Hokanson edited this page Aug 30, 2026 · 1 revision

Forge's own web app is an ordinary client of its own HTTP API — there is no private back channel, so anything the SPA does, another system can do. This page is the integrator's orientation: how the routes are shaped, how you get and keep a token, what an error looks like, how to write safely, and which of the machine-to-machine paths are finished. Per-endpoint request and response detail belongs to the generated OpenAPI document and docs/functional-reference/api-reference.md, not here.

Status: public beta. Breaking changes land inside v1 between betas. Read the versioning section before you build something you have to keep running.


Base shape and versioning

Every controller is routed under a literal api/v1 prefix. The one exception is the .well-known discovery controller (below). There is no API-versioning library in the project, no version header, and no media-type negotiation: v1 is a string in the route template, and during the beta breaking changes land inside v1 rather than producing a v2.

The practical consequence: the image tag is your contract. If you need a stable surface, pin SERVER_IMAGE_TAG and upgrade deliberately — see Upgrades and Rollback. Regenerate your client when you move tags.

Everything is JSON. Send Authorization: Bearer <token> on authenticated calls.

Before you have a token

Three surfaces answer without credentials, and they are the right things to point a monitor or a first integration attempt at:

Endpoint What it gives you
GET /api/v1/version {version, gitCommit, shortCommit, buildLabel} — explicitly anonymous, and exempt from the rate limiter, so it is safe to poll
GET /api/v1/health The aggregated health report as JSON: overall status plus a per-check entry (database, background jobs, object storage, real-time) with duration
GET /.well-known/forge.json Instance discovery — API base address, instance name, permitted auth methods, minimum client version. Written for the mobile app's manual-address path, but it is the cheapest way to ask a box what it is

The interactive reference, and its one condition

The API serves its own OpenAPI document and a Scalar reference UI — but both are registered inside the Development-only branch of startup. Concretely: the generated document at /openapi/v1.json and the browsable UI at /scalar exist when ASPNETCORE_ENVIRONMENT is Development, and do not exist otherwise.

A stock install ships Development, so on a fresh box the reference is there. Setting Production — which you should, see Hardening a Production Install — removes it, along with the developer-only endpoints and the relaxed rate limiting.

So: generate and keep your client from a Development instance, then harden. Do not plan on the docs surface being reachable in production, and do not leave a box in Development to keep it.

Getting a token

POST /api/v1/auth/login with {email, password} returns:

{ "token": "...", "expiresAt": "...", "user": { ... }, "mfaRequired": false, "mfaPendingToken": null }

There is no refresh token. Forge issues a single signed JWT (HMAC-SHA256, a day's lifetime by default) and rotates it in place:

  • POST /api/v1/auth/refresh — call it with the still-valid bearer token. The handler reads the jti from your current token and atomically swaps the session registry to a new one, returning a fresh token. There is no separate credential to store.
  • POST /api/v1/auth/logout — revokes the session.
  • GET /api/v1/auth/me — identity and effective roles, including any roles rolled up from a role template.

Every authenticated request validates the token's jti against the session table, so revocation is durable across API restarts rather than living in process memory; a positive lookup is briefly memory-cached, so a revoke takes effect within that window. A token carrying no jti claim is rejected outright — you cannot hand-mint one against the signing key and expect it to work.

If Architecture told you Forge does refresh-token rotation, that wording is being corrected: it is session (JTI) rotation of the access token.

The second factor: MFA and passkeys

This is the most common first-integration surprise. If the account has MFA enabled and no trusted-device token is presented, login succeeds but returns an empty token with mfaRequired: true and a single-purpose mfaPendingToken. Your client must handle that branch or it will look like a silent failure.

From there you complete the challenge before you have a usable token:

  • POST /api/v1/auth/mfa/challenge then POST /api/v1/auth/mfa/validate — TOTP.
  • POST /api/v1/auth/mfa/recovery — a one-time recovery code.
  • POST /api/v1/auth/passkeys/challenge/options then .../challenge/validateWebAuthn passkeys, which Forge supports as a full second factor alongside TOTP. Registration (passkeys/register/options, passkeys/register) requires an existing session.

A device can be remembered so the challenge is skipped next time; enrolment, status and device removal live under /api/v1/auth/mfa/* on an authenticated session.

Two facts an operator should know before an integration is built: MFA is per user, and an admin can enforce it by role — the policy names roles, and every user holding one is marked as enforced. If your integration authenticates as a human account, a later policy change can break it. That is one of the reasons headless integrations should use an API key bound to a purpose-built service user instead.

Error contract — three envelopes

Most failures come back as RFC 7807 application/problem+json from a single exception middleware, with a consistent status map:

Situation Status Notes
Validation failure 400 ValidationProblemDetailserrors keyed by property name, each an array of messages
Not found 404
Business-rule refusal 409 Forge uses 409, not 422, for "you cannot do that to this record". The reason is in detail
Unauthenticated, or an external token that did not verify 401
Authenticated but not permitted for this row 403
Anything unrecognised 500 Deliberately generic title, no internal detail

Some responses add a machine-readable code extension worth handling explicitly:

  • workflow-readiness-missing (409) — carries a missing array so a client can say "Missing: BOM, Routing". See Workflow Gates and Approvals.
  • device-revoked (401) — the mobile app's signal to wipe local state. See Mobile and Offline.
  • idempotency-key-mismatch (422) — below.

Two responses deviate from problem+json, and a robust client special-cases both:

  1. Model-binding failures return { "errors": [ { "field", "message", "rejectedValue" } ] } — a deliberate replacement for ASP.NET's raw JSON-path text, still on HTTP 400.
  2. Capability refusals return { "errors": [ { "code": "capability-disabled", "capability", "message" } ] } on HTTP 403 plus an X-Capability-Disabled: CAP-… response header.

That header is the one to build on: it is how you tell "this feature is switched off for this install" apart from "this user lacks the role". Both are 403. See Capability Gating — and expect installs to differ, because that is the point of the mechanism.

Lists: pagination, sorting and filtering

Where a list endpoint has been standardised, it accepts a common query contract and returns a common envelope:

  • Querypage (1-based, clamped to at least 1), pageSize (clamped to a bounded range), sort, order (asc/desc), q for free text across the entity's headline columns, and dateFrom / dateTo bounding createdAt. A legacy search parameter is still accepted as an alias for q.
  • Response{ items, totalCount, page, pageSize }.
  • Sort columns are whitelisted per endpoint rather than passed through to the query provider, so an unrecognised sort quietly falls back to that entity's default order (created-date descending for transactional lists, name or part number ascending for master data) instead of erroring.

The honest caveat: this envelope is only partly rolled out. It has been adopted endpoint by endpoint, not across the board — some list endpoints still return a bare JSON array with their own ad-hoc filter parameters. Write your client to detect the shape, and confirm per endpoint against the OpenAPI document rather than assuming. Expect more endpoints to move to the envelope over the beta.

Safe writes

Idempotency. Send an Idempotency-Key header (a bounded-length string) on any POST, PUT, PATCH or DELETE and the request executes once. A replay within the retention window — currently about a day — returns the stored status and body plus an Idempotent-Replayed: true header, without re-running the handler. Details that matter:

  • Keys are scoped per caller — user, shared device, or an anonymous bucket — so they never collide between principals.
  • Reusing a key with a different request body is refused with 422 and code: "idempotency-key-mismatch". The stored outcome is fingerprinted against method, path and body.
  • Responses of 500 and above are deliberately not stored, so a genuine server failure is retried for real rather than replayed as a failure.

This is what makes the mobile offline queue safe to drain, and third-party clients should use it the same way: generate a key per logical operation, retry with the same key.

Optimistic locking. The versioned transactional types — Job, Invoice, Purchase Order, Payment, Shipment, Sales Order, Quote — carry a numeric version. Mutating endpoints on those types return it in an ETag response header; send it back as If-Match on the next PATCH/PUT/DELETE and a stale value is rejected with 412 Precondition Failed.

The check is currently permissive: omit If-Match and no check runs, which preserves existing clients. The intent is to tighten it once clients send it reliably, so treat sending it as best practice now rather than as an optional extra. For what a human sees when this fires, see UI Flowsdocs/ux/concurrency-conflict-ux.md.

Real-time: the SignalR hubs

Five hubs are mapped, all [Authorize], and all outside /api/v1:

Hub Carries
/hubs/board Kanban lanes and job detail
/hubs/notifications Per-user notifications
/hubs/timer Running time entries
/hubs/chat Channels and rooms
/hubs/accounting Accounting sync and posting activity

A WebSocket cannot set an Authorization header, so the bearer handler is wired to also read ?access_token=<jwt> from the query string — and only for paths beginning /hubs or /api/v1/downloads. That is exactly what the standard SignalR client's accessTokenFactory produces, so a stock client works without special handling.

Subscription differs by hub. The notification, timer and chat hubs auto-join a user:{id} group on connect, so per-user pushes need no client call. The board hub is explicit: call JoinBoard(trackTypeId) or JoinJob(jobId) (with matching Leave methods) to subscribe to a lane or a record. Chat adds JoinChannel / JoinRoom and StartTyping / StopTyping.

Hub paths are exempt from the rate limiter, so a long-lived connection with chatty traffic will not throttle itself.

Headless credentials: two API-key schemes

For a system that is not a browser and should not hold a person's password, Forge issues two kinds of key. The difference is not cosmetic — pick deliberately.

System API key BI API key
Binding User-bound — authenticates as a real user Unbound — a synthetic BI-client role
Headers X-Forge-Api-Key: <key> or Authorization: ForgeApiKey <key> X-Api-Key: <key> or Authorization: ApiKey <key>
Reaches Whatever that user's roles reach The read-only /api/v1/bi/* surface only
Attribution Audit rows, activity log and [Authorize(Roles=…)] all see the real user Attributed to the synthetic client, not a person
Issued at /api/v1/admin/system-api-keys (Admin) /api/v1/admin/bi-api-keys (Admin)

A system key is the right choice when the action needs to attribute to somebody — deactivate the bound user and every key bound to it dies with them, which is a useful revocation lever. A key can additionally be pinned to a role template, which is applied as an intersection with the bound user's actual roles: it can only ever narrow, never widen. A key can never mint another key.

A BI key is the right choice for read-only export tooling where attribution to a person would be a fiction. It can be scoped to allowed entity sets and optionally an IP allow-list, and GET /api/v1/bi/whoami exists as a configuration probe — call it first when wiring a reporting tool.

Both key controllers are gated on CAP-IDEN-AUTH-API-KEYS, so a 403 with X-Capability-Disabled on the issuance screen means an admin must enable that capability before any key exists. The BI surface itself is additionally gated on CAP-CROSS-BI-EXPORT.

Keys are stored as a salted PBKDF2 hash behind a short plaintext prefix used for indexed lookup; the plaintext is shown once at issuance and never persisted. Treat it like any other secret — it is not recoverable from the install.

Canonical detail, including the issuance and consumer contract: docs/api-key-integrations.md.

SSO token exchange

If your client is already federated to the same identity provider as the Forge install, POST /api/v1/auth/sso/token-exchange trades a Google, Microsoft or generic-OIDC id_token for a Forge JWT with no browser round-trip. It validates signature, issuer, audience, lifetime and email verification, then reuses the same user lookup, domain policy and session creation as the browser flow — so there is no second code path to drift.

One rule carries straight over from the browser flow and catches people: SSO never provisions accounts. The user must already exist and be active in Forge, or the exchange fails. Roles come from the local account, not from the IdP. See Configuration and Integrations for how providers are registered.

Machine-to-machine, honestly

Today the direction of travel is pull (you call Forge) or push-in (something calls Forge). Outbound push is not finished, and it is worth being blunt about that before you design around it.

Inbound webhooks work. The carrier tracking webhook (POST /api/v1/shipping/tracking-webhook) is anonymous by necessity — a carrier has no Forge session — and guarded by a shared secret header instead, which you configure. It answers quickly and idempotently so a carrier does not retry-storm.

An authenticated acceptance channel exists for customers whose own system posts back: sales-order external acceptance accepts either a staff JWT or a user-bound system API key, and is kept on its own narrow controller so a key never gains the whole sales-order surface.

Outbound webhooks are half-built. Subscriptions can be registered at /api/v1/admin/webhooks (Admin, gated on CAP-CROSS-WEBHOOKS) with a URL, a list of event types, an encrypted signing secret, optional extra headers, delivery-history storage and auto-disable after repeated failures. But the integration outbox dispatcher implements no delivery branch for the webhook provider, so nothing is actually sent. The schema and the admin surface are ahead of the dispatcher. Do not architect on outbound events yet — poll, or drive from your side.

The vocabulary those subscriptions will eventually speak already exists as domain events: job created, job stage changed, sales order confirmed, purchase order created / received / short-closed, shipment created and delivered, QC inspection failed, invoice past due, inventory below reorder, quote expiring, delivery date changed, sequence step ready and clock expired, approval completed. Failed domain-event handlers land in a dead-letter table with a retry path, and outbound provider calls queue through an integration outbox with attempt counts, backoff and a discard/retry admin surface — so when webhook delivery does land, the durability plumbing is already underneath it.

Until then, the supported integration shapes are: poll the API with a system or BI key, push in through the inbound endpoints, and read the database via a BI key's export surface.

When it works from the browser but not from your client

Two configuration facts account for most of these, and both live on Hardening a Production Install:

  • CORS. The default policy allows credentials from a fixed origin list — local development ports and the internal UI service names. Your own domain is not on it. Add it via CORS_ORIGINS and restart the API. Because the policy allows credentials, wildcards are not an option, so list origins explicitly, including www. and subdomains — SignalR's negotiate step checks Origin too, and a missing entry usually shows up as broken real-time features rather than an obvious error. Server-to-server clients are unaffected; CORS is a browser mechanism.
  • Rate limiting. A global fixed-window limiter partitioned by authenticated user or remote IP, no queue, 429 when you exceed it. Hub paths, the version endpoint, developer endpoints and loopback callers are exempt. The limiter is a no-op in Development, which is the shipped default — so an unexpected 429 means you are talking to a Production box, and no 429 under load means you are not hardened yet.

Where to read further

Clone this wiki locally