Trigger Types
Webhook, schedule, and manual trigger configuration
Trigger Types
Every integration declares a single trigger in integration.yaml. The trigger defines when and how the integration runs.
Webhook
The integration runs each time an HTTP request is received at its webhook URL:
POST https://app.headlessops.ai/api/hooks/{workspaceId}/{integrationName}trigger:
type: webhook
method: POST # ANY | POST | GET | PUT (default: ANY)
response_mode: immediate # immediate (default) | synchronousResponse Modes
| Mode | Behavior |
|---|---|
immediate | Returns 202 Accepted instantly; run executes asynchronously |
synchronous | Waits for the run to complete and returns the value from ctx.output() |
Synchronous Webhook Example (JSON)
trigger:
type: webhook
response_mode: synchronousexport async function run(input: Record<string, any>, ctx: StepContext) {
const result = { status: "ok", processed_id: input.record_id };
ctx.output(result); // Returned to the HTTP caller as the response body
return result; // Also available to downstream steps
}The webhook caller receives the ctx.output() value synchronously in the response body as JSON.
Synchronous Webhook Example (HTML)
To return HTML from a synchronous webhook, include a __html key in the value passed to ctx.output(). The platform automatically sets Content-Type: text/html:
export async function run(input: Record<string, any>, ctx: StepContext) {
const html = `<!DOCTYPE html>
<html>
<head><title>Result</title></head>
<body>
<h1>Record Processed</h1>
<p>ID: ${input.record_id}</p>
<p>Status: OK</p>
</body>
</html>`;
ctx.output({ __html: html });
return { status: "ok" };
}Use case: Return styled HTML pages from webhooks — confirmation pages, status dashboards, or custom UIs rendered on-demand.
Synchronous Webhook Example (Binary Response + Custom Headers)
To return binary data (a generated image, PDF, audio file, etc.) directly from a synchronous webhook, include a __base64 key (the base64-encoded body) in the value passed to ctx.output(). Use __headers to set any custom response headers, such as Content-Type or Content-Disposition:
export async function run(input: Record<string, any>, ctx: StepContext) {
const fileBuffer = await generateFile(input); // returns a Buffer/Uint8Array
ctx.output({
__base64: Buffer.from(fileBuffer).toString("base64"),
__headers: {
"Content-Type": "video/mp4",
"Content-Disposition": 'attachment; filename="output.mp4"',
},
});
return { status: "ok" };
}__base64— base64-encoded response body; the platform decodes it and returns the raw bytes.__headers— an object of custom response headers merged into the HTTP response. IfContent-Typeis omitted, it defaults toapplication/octet-stream.__base64and__headerscan be combined with__statusto also control the HTTP status code.
Use case: Serve a generated file (video, PDF, image) directly back to the webhook caller — for example an
ctx.ssh.exec-driven ffmpeg conversion that returns the converted file in the same request.
Webhook Authentication
Protect your webhook with a credential-backed secret header:
trigger:
type: webhook
method: POST
auth:
header: x-api-key # incoming header name to check
credential: MY_WEBHOOK_KEY # workspace credential whose value must matchRequests that do not include the correct header value are rejected with 401.
Setting Up Webhook Auth
Step 1 — Create the credential:
curl -X POST https://app.headlessops.ai/api/credentials \
-H "Authorization: Bearer iter_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "type": "api_key", "name": "MY_WEBHOOK_KEY", "value": "super-secret-value" }'Step 2 — Update integration.yaml and push:
trigger:
type: webhook
method: POST
auth:
header: x-api-key
credential: MY_WEBHOOK_KEYStep 3 — Register the auth settings on the platform:
curl -X PUT https://app.headlessops.ai/api/integrations/my-integration \
-H "Authorization: Bearer iter_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"webhookMethod": "POST",
"webhookAuthHeader": "x-api-key",
"webhookAuthCredentialName": "MY_WEBHOOK_KEY"
}'All three steps are required. Updating only the YAML will not register the auth settings.
Removing Webhook Auth
curl -X PUT https://app.headlessops.ai/api/integrations/my-integration \
-H "Authorization: Bearer iter_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"webhookAuthHeader": null,
"webhookAuthCredentialName": null
}'Also remove the auth: block from integration.yaml and push.
Webhook Settings Reference
| Field | Values | Description |
|---|---|---|
method | ANY | POST | GET | PUT | HTTP method filter. Requests with other methods are rejected (default: ANY) |
response_mode | immediate | synchronous | immediate returns 202 instantly; synchronous waits and returns ctx.output() |
auth.header | string | Header name to check for the secret (e.g. x-api-key) |
auth.credential | credential name | Workspace credential whose value must match the incoming header |
External System Webhook Setup (e.g. Airtable)
When registering the HeadlessOps webhook URL in an external system:
Webhook URL: https://app.headlessops.ai/api/hooks/{workspaceId}/{integrationName}
Method: POST
Headers: x-api-key: <your-webhook-secret>Example Airtable automation script:
let apiKey = input.secret('HEADLESSOPS_WEBHOOK_KEY');
let url = 'https://app.headlessops.ai/api/hooks/ws_abc123/my-integration';
let response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey
},
body: JSON.stringify({ record_id: input.config().record_id })
});
console.log('Status:', response.status);Schedule
The integration runs automatically according to a cron expression (UTC):
trigger:
type: schedule
cron: "0 9 * * 1-5" # weekdays at 9am UTCCommon Cron Expressions
| Expression | Meaning |
|---|---|
* * * * * | Every minute |
0 * * * * | Every hour |
0 9 * * 1-5 | Weekdays at 9am UTC |
0 0 * * * | Daily at midnight UTC |
0 9 * * 1 | Every Monday at 9am UTC |
0 0 1 * * | First day of every month at midnight |
Changing a Schedule
When changing the cron expression, you must do two things:
- Update
integration.yamlwith the newcronfield and push - Call
PUT /api/integrations/:namewithtriggerType: scheduleandcronExpression
Both steps are required. Updating only the YAML will not register the new cron job.
# Step 2 — register the new schedule
curl -X PUT https://app.headlessops.ai/api/integrations/my-integration \
-H "Authorization: Bearer iter_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"triggerType": "schedule",
"cronExpression": "0 9 * * 1-5"
}'Manual
The integration is triggered explicitly by an API call, the web UI, or an MCP tool call:
trigger:
type: manualTrigger a Manual Run
curl -X POST https://app.headlessops.ai/api/integrations/my-integration/runs \
-H "Authorization: Bearer iter_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "payload": { "email": "user@example.com" } }'The payload is available inside steps as ${{ trigger.body.field }}.
Pinned Trigger Payload
For development and testing, create a pinned-trigger-payload.json file in the integration package. The runtime uses this static payload instead of the live event payload during manual and test runs.
For manual triggers (flat structure — maps to trigger.body):
{
"email": "test@example.com",
"plan": "Growth"
}For webhook triggers (must include body, query, headers keys):
{
"query": { "plan": "Growth", "hours": "10" },
"body": {},
"headers": {}
}Common mistake: For webhook triggers, a flat object like
{ "plan": "Growth" }will cause${{ trigger.query.plan }}to resolve to empty at runtime. Always use the{ query, body, headers }wrapper.
Trigger Type Comparison
| Trigger | When to Use | YAML type |
|---|---|---|
| Webhook | React to external events (Airtable updates, Stripe webhooks, form submissions) | webhook |
| Schedule | Recurring jobs (daily syncs, hourly reports, cleanup tasks) | schedule |
| Manual | On-demand scripts, one-time tasks, ad-hoc data operations | manual |
One-Time / Ad-hoc Tasks
Manual trigger integrations are ideal for one-off operations. Use the push-run endpoint to atomically upload files and trigger a run in a single call:
curl -X POST https://app.headlessops.ai/api/integrations/temp-helper-create-event/push-run \
-H "Authorization: Bearer iter_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"files": {
"integration.yaml": "name: temp-helper-create-event\ndescription: Create a Google Calendar event\ntrigger:\n type: manual\nsteps:\n - id: create_event\n name: Create Event\n description: Creates a calendar event\n file: create_event.ts\n credentials: [GOOGLE_CALENDAR_TOKEN]",
"create_event.ts": "export async function run(input: Record<string, any>, ctx: StepContext) {\n ctx.log(\"create_event: start\", input);\n const token = ctx.credentials.GOOGLE_CALENDAR_TOKEN;\n const res = await ctx.http.post(\n \"https://www.googleapis.com/calendar/v3/calendars/primary/events\",\n {\n headers: { Authorization: `Bearer ${token}` },\n json: { summary: input.summary, start: { dateTime: input.start_time }, end: { dateTime: input.end_time } },\n timeout: 25000\n }\n );\n if (!res.ok) throw new Error(`Google API returned ${res.status}`);\n const event = await res.json();\n ctx.log(\"create_event: done\", { id: event.id });\n return { event_id: event.id };\n}"
},
"payload": {
"summary": "Team Standup",
"start_time": "2026-06-10T09:00:00Z",
"end_time": "2026-06-10T09:30:00Z"
}
}'Naming convention for one-off integrations: Prefix the name with
temp-helper-(e.g.temp-helper-airtable-schema-fetch,temp-helper-slack-send-message). This keeps them separate from permanent automations.