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:
- Authentication (admin secret OR JWT) — see Authentication.
- Session-variable extraction from
X-Husaria-*headers. - Context enrichment — request headers, session vars, role, and user_id are copied into the
http.Contextthat the resolver will see. (Before this enrichment,forward_client_headerswas a no-op and RBAC couldn't seeX-Husaria-Role.)
Step 2. Permission check #
The action resolver consults the configured Authorizer:
| Caller | Behaviour |
|---|---|
| Admin (admin-secret authenticated, or any caller in open mode) | Webhook fires unconditionally. |
| Non-admin with a role | Manager.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 Authorizer | Falls 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:
- Raw scalar / object body — what the action contract specifies. Plugged in as-is.
{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:
| Field | Applies to | Effect |
|---|---|---|
method | request | Overrides the outbound HTTP method (otherwise the handler default, POST). |
url | request | Overrides the outbound webhook URL. |
query_params | request | Map of query parameters copied onto the outbound URL. |
template | request & response | A Go text/template rendered against the request or response context. The rendered string becomes the new body. |
content_type | request | Overrides 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:
- Resolver returns a UUID (
async_id) immediately. - The webhook call runs in a detached goroutine outside the request's context.
- Result is persisted to
husaria_data.action_logskeyed byasync_id. - Client polls a follow-up query (or pulls from
action_logsdirectly) 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:
| Kind | Use when |
|---|---|
object | The action returns a structured response with named fields. |
input_object | The action takes a structured input with named fields. |
enum | The field accepts (or returns) one of a fixed set of string values. Carries optional per-value descriptions. |
scalar | An 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 #
| Method | Path | Behaviour |
|---|---|---|
| GET | /api/action-types | List all registered Action Types. |
| GET | /api/action-types/:name | Fetch a single type by name. |
| POST | /api/action-types | Create a new type. Body: {name, kind, fields[]?, enum_values[]?, description?}. |
| PUT | /api/action-types/:name | Update an existing type's fields / enum values / description. |
| DELETE | /api/action-types/:name | Remove 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 viaoutput_type/arguments.action_permissions—(role, action_id)rows consulted byIsActionAllowed.action_logs— execution history. Async results live here keyed byasync_id. Thehttp_statuscolumn 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 theaction_typesDDL 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.