ServicesForms

Execution Hooks

A form can route each accepted submission onward without any glue code. The execution.on_valid_submission array holds hooks, one per target service, and every submission carries an execution_status object recording where each one landed.

"execution": {
  "on_valid_submission": [
    {
      "kind": "fsm",
      "fsm_name": "docs4-intake",
      "target_state": "triage"
    }
  ]
}

Hooks fire once, at submission time, after validation and encryption. They are dispatched best-effort: a hook that fails is recorded as failed and the submission is still stored.

The four kinds

job enqueues a Job Queue task that POSTs the rendered payload to payload_template.webhook_url. The URL is mandatory and checked at registration - a job hook without one is rejected with 400 invalid_form: job execution hook requires a payload_template.webhook_url http(s) target, and a bad scheme with ... is not an allowed target: outbound URL scheme is not allowed. queue selects the queue and defaults to default. Verified end to end: a submission produced an HTTP POST at the configured endpoint carrying the rendered template.

fsm transitions an entity named after the submission_id into target_state on the named FSM, so snug fsm state -n <fsm> -e <submission_id> reports the new state right after a submission. fsm_name and target_state are both required; if either is missing the hook records skipped.

hitl opens a Human-in-the-Loop approval request titled Form submission <submission_id> requires approval, with {"form_id": ..., "submission_id": ...} as its metadata so the request can be traced back. Approvers come from payload_template.approvers; with none listed the submitter approves their own request. The idempotency key is derived from the submission id, so a retry reuses the existing request rather than opening a second one.

webhook enqueues a form.submission event for the webhook named by webhook_id. See the caveat below before relying on it.

Payload templates

payload_template is copied into the outgoing payload with two substitutions, {{submission_id}} and {{form_id}}:

"payload_template": {
  "submission": "{{submission_id}}",
  "form": "{{form_id}}",
  "channel": "ops"
}

With no template at all the payload is {"submission_id": ..., "form_id": ...}. The same field doubles as the hook's configuration surface: webhook_url for job, approvers for hitl.

Approval status versus an approval hook

Two separate switches, easy to conflate:

  • approval.enabled on the form definition sets the submission's own status to pending_approval instead of submitted, and marks execution_status.approval as requested. On its own it creates no approval request anywhere.
  • A hitl hook creates the real HITL request. It leaves the submission's status at submitted unless approval.enabled is also set.

Set both when you want a submission that both reads as pending and has a request someone can act on.

The decision does not flow back. Approving the HITL request leaves the submission at status: submitted (or pending_approval) with execution_status.approval: "requested" unchanged, and no endpoint moves a submission between statuses. Treat the HITL request as the authoritative record of the decision and query it there.

Per-hook status

execution_status has one slot per kind - job, webhook, fsm, approval - each null when the form configures no hook of that kind:

ValueMeaning
queuedjob accepted by the Job Queue
deliveredfsm transition applied, or webhook event enqueued
requestedHITL request created, or approval flagged
skippedhook is configured but missing required fields
faileddispatch errored; the submission was still stored

Retrying

snug form retry --form <form_id> --submission <submission_id> --hook webhook

Hook names are job, webhook, fsm, and approval (hitl is accepted as an alias). Only the form owner or a platform admin may retry; the submitter themselves gets 403 insufficient_permissions. A name the form does not configure is 400 invalid_request: hook 'fsm' is not configured for this submission, and a name that is not a hook kind at all is unknown hook 'email'. A retry re-dispatches the hook and updates that one slot, leaving the rest of execution_status alone. Retrying a hitl hook reuses the existing approval request rather than opening a second one - verified by retrying and finding the request count unchanged.

Caveat on the webhook kind

execution_status.webhook: "delivered" means the event was enqueued onto the webhook delivery stream, not that any endpoint answered. As of this writing those events do not reach their endpoint: the form service emits event type form.submission, which is not in the webhook service's event catalog, so the delivery worker cannot parse it and dead-letters every one - snug webhooks dlq <webhook_id> reports Failed to parse event: unknown variant 'form.submission'. Until that is reconciled, use a job hook for outbound HTTP: it delivers through the Job Queue and was confirmed reaching its endpoint.

Reference

On this page