An automation hook is a webhook subscription. An automation platform such as Make registers one when a scenario’s instant trigger is switched on and deletes it when the trigger is switched off. When a subscribed event happens in the studio, Perform posts a JSON payload to the hook’s URL. Source: backend/apps/core-api/src/modules/automation-hooks/. The module has a controller, service, repository, schema, dispatcher and delivery file, and no routes file of its own.

Mounting and auth

The controller is built and mounted inside automationApiRouter(ctx) in modules/automation-api/automation-api.routes.ts:
modules/index.ts mounts that router at /v1/automation with no web lane middleware, so the full paths are /v1/automation/hooks. The general /v1 rate limiter from app.ts applies. Authentication is requirePartnerKey from modules/partner/partner-auth.ts, applied to the whole /v1/automation router:
  • The request must send Authorization: Bearer YOUR_API_KEY, where the key starts with pf_live_. Anything else fails with missing partner api key.
  • The key is hashed with SHA-256 and looked up through the partner repository. A key that is unknown, revoked, or whose studio is archived fails with invalid partner api key.
  • On success the request carries partner with studioId and apiKeyId. lastUsedAt on the key is stamped at most once every 5 minutes.
Keys are studio API keys, the same ones the /v1/partner lane uses. There are no roles on this lane. One key maps to exactly one studio.

Response envelope on this lane

/v1/automation does not use the standard envelope described in API overview. It uses automation-api.envelope.ts. Success is always HTTP 200:
Errors carry a readable sentence and no error code field:
Status mapping in automationErrorMiddleware: So a hook that does not exist answers 400 here, not 404.

Events

HOOK_EVENTS in automation-hooks.schema.ts has one value: FORM_FILLED.

Endpoints

POST /v1/automation/hooks

Registers a webhook for the calling API key. Auth: studio API key (requirePartnerKey).
string
default:"FORM_FILLED"
Only FORM_FILLED is accepted.
string
required
Trimmed, a valid URL, up to 2048 characters. Must use https and a public host. Hosts that are refused: localhost, addresses starting with 127., 10., 192.168., 169.254. or 172.16. to 172.31., the IPv6 loopback, and names ending in .local or .internal.
string
Limits the hook to one form. Empty, blank or null means every form of the studio.
What the service does:
  1. Counts the hooks already registered by this key. At MAX_HOOKS_PER_KEY = 50 the request is refused.
  2. When formTemplateId is sent, checks that the form template belongs to the key’s studio.
  3. Generates a signing secret (24 random bytes, hex encoded) and creates the AutomationHook row.
  4. Writes an AuditLog row with action automation.hook.register, actorId set to the API key id, resourceType AUTOMATION_HOOK and the hook id.
Response: message Webhook registered. data.hook contains the hook and its secret. The secret is returned only here. The list endpoint never returns it.
Errors:
  • 401 missing partner api key or invalid partner api key.
  • 400 Invalid request: ... for a schema failure, for example targetUrl must be an https URL on a public host.
  • 400 this key already has 50 webhooks; delete one before adding another (BAD_REQUEST).
  • 400 form not found (NOT_FOUND) when formTemplateId is not in the studio.

GET /v1/automation/hooks

Lists the hooks registered by the calling API key, newest first. Hooks registered by another key of the same studio are not included. Auth: studio API key.
string
Optional filter. Only FORM_FILLED is accepted.
Response: message Webhooks listed.
lastDeliveryAt and lastStatus are updated after each delivery attempt. lastStatus is null when the last attempt got no HTTP response. The stored failureCount is not returned. Errors: 401 for a bad key. 400 Invalid request: ... for an unknown event.

DELETE /v1/automation/hooks/:id

Removes one hook. The delete is scoped to the calling key, so a key cannot remove a hook another key registered. Auth: studio API key.
string
required
The hook id.
Deletes the row and writes an AuditLog row with action automation.hook.remove. Response: message Webhook removed.
Errors: 400 webhook not found (NOT_FOUND) when no row matched the id for this key. 401 for a bad key.

The AutomationHook row

Dispatcher

automation-hooks.dispatcher.ts turns a studio event into queued deliveries.

Who emits

The only emitter of FORM_FILLED is formSubmittedHandler(ctx) in modules/forms/forms.wiring.ts. It is the submit hook of the wired forms service (createWiredFormsService), so it runs for every submission that goes through that service. See Forms. It calls dispatchFormFilled(ctx.prisma, hookQueue, event, ctx.logger) first and without awaiting it, so the trainee’s submit does not wait on Redis and a task automation failure cannot cost the webhook. A failure to dispatch is logged as automation hooks: dispatch failed. After that the same handler runs the FORM_FILLED and FORM_RATING_BELOW task automations described in Tasks engine. The forms service built inside automation-api.routes.ts is constructed without a submit hook.

dispatchFormFilled

  1. repo.matching(studioId, 'FORM_FILLED', formTemplateId) loads active hooks of the studio for that event whose formTemplateId is null or equals the submitted form. Hooks of an archived studio are excluded.
  2. With no matching hook it returns 0 and does no further work.
  3. buildFormFilledPayload builds one payload. It returns null when the response row is gone, and nothing is queued.
  4. One job per hook is added to the queue with the hook id and the shared payload. A queueing failure for one hook is logged and the others continue.

Payload: form_filled

Delivery

deliverHook(prisma, job, logger) in automation-hooks.delivery.ts posts one payload. It has two modes, selected by the job.

Registered hooks

The job carries hookId. The worker loads the hook. A hook that is gone or not active is dropped without a request. The body is JSON.stringify(payload). The request is an HTTP POST to targetUrl with a 10 second timeout and these headers: To verify a delivery, compute the same HMAC over the raw body bytes with the secret returned at registration and compare it to the header.
The response body is never read. After the attempt, recordDelivery stamps lastDeliveryAt and lastStatus, and increments failureCount or resets it to 0.

Direct webhooks from flows

The job carries directUrl and no hook id. This is the Webhook node of the flow builder. See Task automations. There is no hook row and no secret. The request is a POST with Content-Type: application/json and X-Perform-Event set to the job’s event (automation_flow). There is no X-Perform-Signature and no X-Perform-Hook-Id header, and nothing is recorded on a hook row. The body is the flow payload:
Here event is the task trigger type of the flow run.

Outcomes and retries

Queueing and retries live in workers/hook-delivery-worker.ts:
  • Queue: automation-hook-deliver (PerformQueue.AUTOMATION_HOOK_DELIVER), job name deliver.
  • createHookQueue(ctx).enqueue adds the job with 5 attempts and exponential backoff starting at 10 seconds.
  • startHookDeliveryWorker(ctx) is started from server.ts with concurrency 5. It throws only when the outcome asks for a retry, which is what makes BullMQ schedule the next attempt.
  • After the last attempt the failure is logged as automation hook gave up. Completed jobs are removed. The last 100 failed jobs are kept.
A hook is not deactivated automatically after repeated failures. failureCount keeps counting until a delivery succeeds.

Who uses the hook queue