HeadlessOps
Integrations

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) | synchronous

Response Modes

ModeBehavior
immediateReturns 202 Accepted instantly; run executes asynchronously
synchronousWaits for the run to complete and returns the value from ctx.output()

Synchronous Webhook Example (JSON)

trigger:
  type: webhook
  response_mode: synchronous
export 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. If Content-Type is omitted, it defaults to application/octet-stream.
  • __base64 and __headers can be combined with __status to 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 match

Requests 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_KEY

Step 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

FieldValuesDescription
methodANY | POST | GET | PUTHTTP method filter. Requests with other methods are rejected (default: ANY)
response_modeimmediate | synchronousimmediate returns 202 instantly; synchronous waits and returns ctx.output()
auth.headerstringHeader name to check for the secret (e.g. x-api-key)
auth.credentialcredential nameWorkspace 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 UTC

Common Cron Expressions

ExpressionMeaning
* * * * *Every minute
0 * * * *Every hour
0 9 * * 1-5Weekdays at 9am UTC
0 0 * * *Daily at midnight UTC
0 9 * * 1Every 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:

  1. Update integration.yaml with the new cron field and push
  2. Call PUT /api/integrations/:name with triggerType: schedule and cronExpression

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: manual

Trigger 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

TriggerWhen to UseYAML type
WebhookReact to external events (Airtable updates, Stripe webhooks, form submissions)webhook
ScheduleRecurring jobs (daily syncs, hourly reports, cleanup tasks)schedule
ManualOn-demand scripts, one-time tasks, ad-hoc data operationsmanual

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.