/v1/automation is the lane automation platforms talk to. It was built for Make first, and the same surface works for any HTTP client. The MCP server and the WhatsApp AI assistant also execute their tools through it. Source: apps/core-api/src/modules/automation-api. Webhook subscriptions under /hooks are in apps/core-api/src/modules/automation-hooks and are documented in Outbound webhooks. Base URL in production: https://perform-api.otherwise.co.il/v1/automation.

Authentication

A studio API key as a bearer token:
The lane uses the same requirePartnerKey middleware as the partner lane. Every request resolves to exactly one studio, and all reads and writes are scoped to it. See Partner API and API keys for how keys are created and verified.

Envelope

This lane does not use the standard ok and data envelope, and it does not share the global error middleware. Automation clients show a failed step’s message straight to a coach, so every error has to read as a sentence that says what to fix. Success, always HTTP 200:
Failure:

Error mapping

automationErrorMiddleware in automation-api.envelope.ts is registered on this router only.
backend/docs/MAKE_MODULE_AGENT_PROMPT.md says every response is HTTP 200. That is not what the code does. Failures carry real status codes. A client should treat a response as failed when success is false, whatever the status.

Input coercion

Automation platforms serialize everything as strings and send empty strings for “not filled in”. The schemas in automation-api.schema.ts coerce far more than the web lane so a coach does not need formula steps to satisfy the validator. Pagination on list endpoints: limit (default 50, max 200) and offset (default 0).

Endpoints

Connection

Trainees

GET /trainees/search query: q, status (a ClientStatus), coachId, tag, createdAfter, updatedAfter, limit, offset.

POST /trainees

string
required
string
required
string
required
Normalized to Perform’s canonical form before anything else.
string
string
Your own id for this trainee. The strongest key the API has.
date
string
MALE, FEMALE or OTHER.
number
number
string
integer
integer
string[] or comma-separated string
string[]
Up to 20.
string
A plan to assign at creation.
date
integer
Required when the plan does not fix its own price.
integer
Required when the plan does not fix its own duration. The error names the plan and the field.
string
DAYS or MONTHS.
string
string
string
WEEKLY, BIWEEKLY or MONTHLY.
boolean
default:"false"
Send the WhatsApp invite. Off by default, so a bulk import never texts hundreds of people by surprise.
string
default:"error"
error, return_existing or update.
There is no notify field. Creating a trainee reaches them in exactly one way, the WhatsApp invite, which has its own flag. How duplicates are resolved, in createTrainee:
  1. externalId is checked first. If a trainee with that external id exists, it is returned with created: false. This is what makes a retried scenario safe.
  2. Then the phone, matched through phone variants. What happens next depends on onDuplicatePhone: error fails with CONFLICT and a message naming the existing trainee, return_existing returns it, update applies the body to it.
  3. Otherwise a new trainee is created inside withTraineeRoom, so the plan’s trainee limit applies.
The response is { trainee, created }. External ids are stored namespaced in Client.externalUserId as automation:<studioId>:<externalId>. Responses and webhook payloads hand back only the part the caller supplied.

DELETE /trainees/:id

Body: confirm, default false. Without confirm: true nothing is deleted and the response is a success envelope explaining that deleting a trainee erases their whole history and cannot be undone. That is deliberate. A scenario wired without reading the warning gets an explanation, not a validation error that hides why nothing happened.

Subscriptions

Note the singular key on assign and the plural on the three lifecycle calls. That is the API. When startedOn is omitted, the new plan starts when the trainee’s last live plan ends, not today. Today would land inside the running plan and be refused by the overlap guard, which is what every renewal of an active trainee was hitting.

Programs

Forms

POST /trainees/:id/forms/assign body: notify deliberately has no default: For a document to sign (PDF_SIGNATURE) nothing is sent to the trainee app. The response carries signingUrl, the trainee’s personal signing link, and the message is “Signing link created”. The caller delivers that link itself.

Weights

Messages

POST /trainees/:id/message with message (1 to 1000 characters) sends a push notification to the trainee app. It returns { sent, selected, unreachable, disabled }. sent counts devices that actually accepted. sent: 0 is not an error. It means no reachable device.

Tasks

Plans

GET /products lists the studio’s plans. Query search, limit, offset. priceAgorot and durationValue can be null, which means the plan fixes no value and it is set per trainee. 0 means free. They are different facts. These return short id and label lists for a client’s dropdowns:

Webhooks

POST /hooks, GET /hooks, DELETE /hooks/:id. See Outbound webhooks.

Pagination shapes

Five endpoints return { items, total, limit, offset, hasMore }: /trainees/search, /tasks, /products, /program-templates and /forms. Four return only { items } and cannot page: /trainees/:id/programs, /trainees/:id/form-responses, /trainees/:id/weights and /trainees/:id/subscriptions.

Notifications from this lane

Bulk automation must not spray push notifications at real trainees. The notifier is injected at construction and not per call, so the router keeps two service instances, one loud and one silent, and picks per request from the notify flag. “Silent” only means no instant push from this process. A form assigned silently is still picked up by the hourly pending-form notifier, unless the request said notify: false outright.

Audit

Writes are recorded in AuditLog with the API key id as the actor. Actions written by this lane: automation.trainee.create, automation.trainee.delete, automation.subscription.assign, automation.program.assign, automation.task.create, and from the hooks module automation.hook.register and automation.hook.remove.

Plan limits

Creating a trainee goes through the studio’s plan guard. A studio at its trainee limit gets HTTP 403 with a message saying so.

Existing documentation

backend/docs/AUTOMATION_API.md is a prose guide to this lane and backend/docs/AUTOMATION_API_PLAN.md is the original plan. Where they disagree with the Zod schemas and the routes, the code is right.