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 insideautomationApiRouter(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 withpf_live_. Anything else fails withmissing 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
partnerwithstudioIdandapiKeyId.lastUsedAton the key is stamped at most once every 5 minutes.
/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:
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.- Counts the hooks already registered by this key. At
MAX_HOOKS_PER_KEY= 50 the request is refused. - When
formTemplateIdis sent, checks that the form template belongs to the key’s studio. - Generates a signing secret (24 random bytes, hex encoded) and creates the
AutomationHookrow. - Writes an
AuditLogrow with actionautomation.hook.register,actorIdset to the API key id,resourceTypeAUTOMATION_HOOKand the hook id.
Webhook registered. data.hook contains the hook and its secret. The secret is returned only here. The list endpoint never returns it.
- 401
missing partner api keyorinvalid partner api key. - 400
Invalid request: ...for a schema failure, for exampletargetUrl 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) whenformTemplateIdis 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.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.
AuditLog row with action automation.hook.remove.
Response: message Webhook removed.
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 ofFORM_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
repo.matching(studioId, 'FORM_FILLED', formTemplateId)loads active hooks of the studio for that event whoseformTemplateIdisnullor equals the submitted form. Hooks of an archived studio are excluded.- With no matching hook it returns 0 and does no further work.
buildFormFilledPayloadbuilds one payload. It returnsnullwhen the response row is gone, and nothing is queued.- 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 carrieshookId. 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.
recordDelivery stamps lastDeliveryAt and lastStatus, and increments failureCount or resets it to 0.
Direct webhooks from flows
The job carriesdirectUrl 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:
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 namedeliver. createHookQueue(ctx).enqueueadds the job with 5 attempts and exponential backoff starting at 10 seconds.startHookDeliveryWorker(ctx)is started fromserver.tswith 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.
failureCount keeps counting until a delivery succeeds.