Extending the API

Actions

Actions #

Actions let you register custom GraphQL fields whose resolution is delegated to an external HTTP webhook. They follow a defined webhook actions contract: declare a field name plus input/output GraphQL types plus a webhook URL, and Husaria forwards every call to that URL with a defined envelope.

Actions are wired end-to-end into the live schema — they show up in GraphiQL, respect RLS-style permissions, and can be sync or async.

When to use an action #

  • You need to call an external service (payment gateway, email provider, CRM) as part of a mutation.
  • You need to wrap business logic that doesn't reduce to a single SQL statement.
  • You want a custom query/mutation field surface alongside the auto-generated table CRUD.

For pure database reads/writes — even complex ones — direct GraphQL against your tables is almost always faster and simpler than an action.

Not sure which surface fits? See the side-by-side comparison of Actions vs Delegations vs Events on the introduction page.

Field naming #

Every action lands as action_<name> on the appropriate root type (Query or Mutation, declared per action). The prefix is idempotent — registering an action named action_send_email does not become action_action_send_email. Each field's description carries [Action] webhook: <url> so the GraphiQL docs panel shows the call site without a separate catalog lookup. When the action has a description or comment, the form is [Action] <desc> (webhook: <url>) instead.

Lifecycle #

Step 1. Receive the request #

A client hits /v1/graphql with a query that selects an action_<name> field. The middleware chain runs:

  1. Authentication (admin secret OR JWT) — see Authentication.
  2. Session-variable extraction from X-Husaria-* headers.
  3. Context enrichment — request headers, session vars, role, and user_id are copied into the http.Context that the resolver will see. (Before this enrichment, forward_client_headers was a no-op and RBAC couldn't see X-Husaria-Role.)

Step 2. Permission check #

The action resolver consults the configured Authorizer:

CallerBehaviour
Admin (admin-secret authenticated, or any caller in open mode)Webhook fires unconditionally.
Non-admin with a roleManager.IsActionAllowed(role, action_name) runs an EXISTS lookup against husaria_data.action_permissions. Deny → a plain GraphQL error (action %q: role %q not permitted). It does not carry an extensions.code.
Empty role + nil AuthorizerFalls back to legacy "trust caller" path. Suitable only for open-mode dev.

Step 3. Webhook call #

The executor POSTs JSON to the configured webhook URL. The envelope follows the action contract:

{
  "action": { "name": "action_send_email" },
  "input": { ... },
  "session_variables": { "x-husaria-role": "user", "x-husaria-user-id": "..." }
}

Header forwarding #

The action's configured forward_headers list controls which inbound request headers are propagated to the webhook. Header-name lookup is case-insensitive against the lowercased map — Authorization matches authorization matches AUTHORIZATION.

Retries #

Default maxRetries is 0. The retry loop respects ctx.Err() between attempts and short-circuits on context.DeadlineExceeded or context.Canceled. Set retries explicitly per action if the upstream service expects them.

Response parsing #

processWebhookResponse accepts two body shapes:

  1. Raw scalar / object body — what the action contract specifies. Plugged in as-is.
  2. {data, errors} envelope — kept for back-compat with older deployments that wrap responses.

Step 4. Return to client #

The parsed response is folded into the GraphQL result alongside any sibling fields the query selected. Errors propagate as standard GraphQL errors with extensions carrying the action context.

Request / response transforms #

An action can rewrite the outbound webhook request and the inbound response with two optional TransformRules — request_transform and response_transform, both stored as JSONB columns on the actions table. A TransformRule has five fields:

FieldApplies toEffect
methodrequestOverrides the outbound HTTP method (otherwise the handler default, POST).
urlrequestOverrides the outbound webhook URL.
query_paramsrequestMap of query parameters copied onto the outbound URL.
templaterequest & responseA Go text/template rendered against the request or response context. The rendered string becomes the new body.
content_typerequestOverrides the outbound Content-Type header.

Any field left empty is a pass-through — the value derived from the handler / default marshalling is kept. An empty template means the body is left untouched.

The template is plain Go text/template. A parse or execution error fails the call with a GraphQL error — it is never silently swallowed.

Request template context — the dot (.) is the webhook request, exposing:

  • .Action{{ .Action.Name }}
  • .Input — the field arguments, e.g. {{ index .Input "id" }}
  • .SessionVariables — e.g. {{ index .SessionVariables "x-husaria-role" }}
  • .RequestQuery — the raw GraphQL query string

Response template context — the dot (.) is the decoded response payload, whose shape is whatever the webhook returned (object, slice, scalar). Address it directly: {{ .field }} for an object, {{ . }} for a scalar.

Async actions #

Set the action as async to opt into fire-and-return behaviour:

  1. Resolver returns a UUID (async_id) immediately.
  2. The webhook call runs in a detached goroutine outside the request's context.
  3. Result is persisted to husaria_data.action_logs keyed by async_id.
  4. Client polls a follow-up query (or pulls from action_logs directly) to retrieve the eventual outcome.

This lets long-running webhooks (sync-to-CRM, image generation, batch processing) avoid blocking the GraphQL request thread.

Managing actions #

Through the console #

The Actions tab in the embedded console (/console) gives you a UI for CRUD on the actions catalog: register, edit, delete, permission-gate per role. Calls land on the dedicated /api/actions/* admin API.

Through HTTP #

Actions are also manageable via /api/actions/* directly. This group is gated by the admin-secret middleware — calls without X-Husaria-Admin-Secret (or in secured mode without a valid admin secret) are rejected.

In read-only replica mode (HUSARIA_READ_ONLY=true), writes to /api/actions/* return 503. Reads still pass.

Action Types — custom GraphQL types #

By default, action inputs and outputs use the built-in scalars (String, Int, Boolean, JSON). When that isn't enough, you can register custom GraphQL types that an action can reference instead.

Each Action Type has a kind:

KindUse when
objectThe action returns a structured response with named fields.
input_objectThe action takes a structured input with named fields.
enumThe field accepts (or returns) one of a fixed set of string values. Carries optional per-value descriptions.
scalarAn opaque scalar with a custom name (e.g. DateTime, URL, UUID). Useful when your wire format is already a string but you want stronger typing in your schema.

HTTP surface #

MethodPathBehaviour
GET/api/action-typesList all registered Action Types.
GET/api/action-types/:nameFetch a single type by name.
POST/api/action-typesCreate a new type. Body: {name, kind, fields[]?, enum_values[]?, description?}.
PUT/api/action-types/:nameUpdate an existing type's fields / enum values / description.
DELETE/api/action-types/:nameRemove a type. The delete is unconditional — it does not check for referencing actions. There's no cascade: an action that still references the dropped type will parse-fail on the next schema rebuild until you edit its output_type. Drop or repoint the referencing action(s) afterward.

All five endpoints are gated by AdminSecretGinMiddleware. In read-only replica mode (HUSARIA_READ_ONLY=true), writes return 503; reads still pass.

Console #

The Action Types view lives under the Actions tab in /console. A small sub-nav strip at the top of both the Actions list and the Action Types list lets operators flip between the two without leaving the section — Action Types are scoped to actions, so they don't need their own top-level sidebar entry. The Actions form's output-type field is a combobox populated from /api/action-types, so you pick from the registered catalogue rather than typing a free-form name.

Storage #

Four catalog tables under husaria_data:

  • actions — name, type (query / mutation), webhook URL, timeout, forward_headers, async flag, custom-type bindings via output_type / arguments.
  • action_permissions(role, action_id) rows consulted by IsActionAllowed.
  • action_logs — execution history. Async results live here keyed by async_id. The http_status column carries the webhook's response code on every attempt.
  • action_types (a.k.a. custom_types) — operator-defined GraphQL types referenced by actions. Columns: name, kind (object / input_object / enum / scalar), fields (JSONB array — for object / input_object kinds), enum_values (JSONB array — for enum kind), description. The schema-builder reads this table on every rebuild; failure is soft — plain scalar-only actions still register if the action_types DDL has drifted.

What actions are not #

  • Not event triggers. Actions respond synchronously (or async-polled) to client requests. They are not hooks on Postgres DDL / DML.
  • Not remote schemas. A remote schema would expose the entire upstream GraphQL surface as a federated subgraph. An action exposes a single field per registration.
  • Not a generic webhook caller. The resolver follows the action contract on the wire. Adapt the webhook to the contract — or layer a translator in front of it.