Perform sends HTTP POST requests to external URLs in two situations. Both go through the same queue and the same delivery function. Source: apps/core-api/src/modules/automation-hooks and apps/core-api/src/workers/hook-delivery-worker.ts.

Events

HOOK_EVENTS in automation-hooks.schema.ts has one entry today, FORM_FILLED. The event column is a string and the lane is generic. Adding an event is a new value in that list plus a call to a dispatcher from wherever the event happens.
The subscription name and the payload name differ in case. You subscribe to FORM_FILLED, the header says FORM_FILLED, and the payload’s event field is form_filled.

Registered hooks

Register

string
default:"FORM_FILLED"
One of HOOK_EVENTS.
string
required
Where deliveries go. Up to 2048 characters. Must be https on a public host.
string
Leave empty to receive every form. Set it to receive only that form. An id that is not in the studio is NOT_FOUND.
Response, in the automation envelope:
The secret is 24 random bytes as hex. It is returned once, here, and never again, the way an API key is. Store it. Rules:
  • A key can hold at most 50 hooks (MAX_HOOKS_PER_KEY). The next one fails with BAD_REQUEST and a message telling you to delete one first.
  • Registering and removing a hook are audited as automation.hook.register and automation.hook.remove, because a webhook sends studio data to an address a partner chose.

The target URL fence

A hook URL is attacker-supplied as far as the server is concerned, and it is the server that makes the request. hookTargetUrl refuses anything that is not https, and any host matching localhost, 127., 10., 192.168., 169.254., 172.16. through 172.31., the IPv6 loopback, or a .local or .internal name.

List and remove

Hooks are scoped to the key that registered them. One key cannot list or delete another key’s hooks, even in the same studio. Deleting a StudioApiKey row cascades to its hooks. A row in AutomationHook is a live subscription, not a setting a coach maintains. Make creates it when a scenario’s instant trigger is switched on and deletes it when it is switched off.

The form_filled payload

Built by buildFormFilledPayload in automation-hooks.dispatcher.ts.
fields exists so a receiver can map an answer straight off the trigger with its label, without following ids back with a second call. It is built from the schema snapshot stored on the response, so it reflects the form as the trainee saw it. For a signed document, response.answers holds every value: the coach-filled fields, the trainee’s fields and the signature image URLs.

The flow webhook payload

Sent by webhookEffect in task-automations/flow-runtime.ts when a flow run reaches a webhook node.
Headers: Content-Type: application/json and X-Perform-Event: automation_flow. There is no signature and no hook id, because there is no hook row and no stored secret. The URL was validated when the flow was saved, and the payload holds nothing the studio did not put in its own flow. A receiver that needs authentication should put an unguessable token in the URL. The FlowNodeExecution ledger makes sure a node is enqueued once per run, even across crash retries.

Delivery

Dispatch is best effort

dispatchFormFilled is called from forms.wiring.ts after the submission is saved, without awaiting it. No webhook is worth failing a trainee’s submission for. If queueing one delivery fails, it is logged and the others continue. Matching hooks are those in the studio, active, subscribed to the event, in a studio that is not archived, and either unscoped or scoped to that form.

Request

Timeout: 10 seconds (TIMEOUT_MS). The response body is never read. It is cancelled so the socket is released.

Retries

Every failed attempt increments failureCount and stamps lastStatus.
failureCount is recorded, and active exists on the row, but no code was found that switches a hook to inactive after repeated failures. A dead endpoint keeps receiving attempts for each new event until its hook is deleted.
Delivery is queued, so a receiver sees the event a moment after the submission, not during it. Deliveries for different events are not ordered. Use submissionNumber and occurredAt if order matters.

Verifying a delivery

Compute the HMAC over the raw body bytes, before parsing JSON. Parsing and re-serializing changes the bytes and the signature no longer matches.
Receivers should also:
  • Answer 2xx quickly and do the work afterwards. Anything slower than 10 seconds is a timeout and a retry.
  • Treat deliveries as at-least-once. A timeout after the receiver already processed the event leads to a second delivery. Deduplicate on response.id.
  • Answer 4xx only when the hook should stop receiving that delivery for good.
There is no timestamp in the signature, so a captured request can be replayed. Deduplicating on response.id covers that.

Testing

  1. Register a hook with an empty formTemplateId.
  2. Confirm it with GET /v1/automation/hooks.
  3. Send yourself a form from the coach web app and fill it in the trainee app.
  4. Check lastDeliveryAt and lastStatus on the hook.
The Make app wraps exactly these calls. See Make app.