> ## Documentation Index
> Fetch the complete documentation index at: https://docs.plungeai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Bot triggers and hooks

> Start a bot from outside with a signed webhook, an email address, a Slack channel, new Gmail or a Git event. Send a bot's run events out through signed hooks. Forward a webhook with no model call.


A trigger starts a bot from an event outside the platform. Every trigger hangs off a schedule of the bot (see [Schedules and wake](/bots/schedules-and-wake)), so the run goes through the same stored policy, budget and delivery as any other run. Hooks go the other way: they send the bot's run events to a URL you own.

## Inbound

| Trigger | You get | Where to set it |
|---|---|---|
| Webhook | An https URL and a secret | REST, MCP, CLI |
| Email | An address | REST, MCP, CLI |
| Slack channel or keyword | A bot started by a message | Studio Triggers, Slack card |
| Gmail new mail | A bot started once per new mail | Studio Triggers, Gmail card |
| GitHub or GitLab event | A webhook preset with an event allow-list | Studio Triggers, Git card (the Studio route to a webhook) |

The REST routes live under `/v1/triggers/{kind}`. Every call names the schedule with `schedule_id`: in the body for `POST`, in the query for `GET` and `DELETE`. Mint answers `201` with `Cache-Control: no-store`. **A secret is in the mint response once and is never listed again.** Revoking an id that is not on the schedule is `404 trigger_not_found`. Webhook and email triggers also rotate: `POST /v1/triggers/{webhook|email}/{token}/rotate` mints a successor with the same settings and a new secret, then revokes the old token.

### Webhook

```bash
curl -s -X POST https://api.plungeai.com/v1/triggers/webhook \
  -H "Authorization: Bearer $PLUNGE_API_KEY" -H 'Content-Type: application/json' \
  -d '{"schedule_id":"<id>","scheme":"github","prompt_template":"New PR: {pull_request.title}","filters":{"all":[{"path":"action","op":"equals","value":"opened"}]}}'
```

The response carries `token`, `secret`, `scheme` and the `url` to give the sender.

| Field | Meaning |
|---|---|
| `scheme` | How the sender signs: `generic` (default, `X-Webhook-Signature` is the hex HMAC-SHA256 of the raw body), `github`, `gitlab` or `standard-webhooks`. |
| `prompt_template` | The run's input. `{dotted.path}` fills in a field of the JSON payload. |
| `filters` | `{all?, any?}` of rules `{path, op, value?}`, up to 20. `op` is `equals`, `contains`, `exists` or `in`. A path that starts with `headers.` reads a request header. |

What the sender gets back, in order:

| Answer | When |
|---|---|
| `401` | Bad signature, or a timestamp more than 300 seconds off. |
| `409` | A delivery id seen in the last hour. |
| `204` | The filters did not match. The delivery is acknowledged, no run starts and nothing is spent. |

### Email

`POST /v1/triggers/email` with `{"schedule_id": "..."}` returns a `token` and an `address_local_part` of the form `bot+<token>`; the address is that local part at the Email Routing domain your operator set up. Mail to it starts the schedule's bot. `allowed_from` (comma-separated addresses or domains) restricts the senders; it is a sender filter, weaker than a webhook signature.

### Slack channel or keyword

<Note>
Answers 503 `slack triggers not enabled yet` until the platform owner enables it; the API documents the code.
</Note>

In Studio, open the bot's Triggers and add a Slack trigger: a channel, and optionally a keyword. A human message in the channel that contains the keyword (the longest keyword wins; no keyword means every message) starts the bot with the input `from <@author> in <#channel>: text`. The run is signed as the trigger owner, so the bot's policy and the owner's budget apply.

- App mentions, bot posts, edits and other subtypes never match, so there is no loop. Thread replies in the channel do fire.
- Creating the trigger requires your own membership of the channel, checked server-side and fail-closed.
- The channel can be added to the job's delivery, so the result lands there.
- Anyone who can post in the channel spends the owner's budget: pick channels you trust.

### Gmail new mail

In Studio, add a Gmail trigger: a Gmail search such as `from:billing@ subject:invoice` and a cadence of 5, 15 or 60 minutes. Studio saves a small check workflow and a heartbeat schedule. Each tick the scheduler runs the check as the owner, using the owner's own Google connection, and a YES starts the bot once per mail it has not started before, with the mail (From, Subject, Date, id and the plain-text body, up to 8,000 characters) as the run's input.

- A mail starts at most one run. Only mail that arrived after the trigger was created counts, with at most 5 runs per tick.
- A failed run is not retried, because a retry could repeat side effects. It shows in the bot's run history.
- This is a poll: the cadence is the latency, and the YES or NO check costs one small model call per tick. A tick that finds no new mail puts nothing on the board.
- Mail content is untrusted input. Choose searches you trust and keep the bot's tools narrow.

### GitHub and GitLab

The Git card mints a `github` or `gitlab` webhook with an allow-list of events, then shows the URL and the secret once and where to paste them: in GitHub, repository Settings, Webhooks, Payload URL and Secret with `application/json`; in GitLab, Settings, Webhooks, URL and Secret token. Events outside the allow-list, including GitHub's `ping`, are acknowledged and ignored.

### Deliver-only

A job with `parameters.deliver_only: true` never calls the engine or a model. When its trigger fires, the scheduler fills `parameters.template` (`{input}` is the incoming text) and posts it to the job's `deliver[]` targets, so a webhook can be forwarded to a channel at no model cost. The run history shows the filled text and no cost.

- Slack and Discord pings and links in the incoming text (`@everyone`, `<!channel>`, `<@U...>`, `<https://x|label>`) are broken with an invisible character. Your own template is posted as written.
- Other markup passes through, so put a `filters` allow-list on the trigger, and `allowed_from` on an email trigger.
- A deliver-only job needs a non-empty `deliver[]` and a `template`.

## Outbound hooks

<Note>
Answers 503 `unavailable` until the platform owner enables it; the API documents the code.
</Note>

A hook sends the schedule's run events to an https URL you own.

```bash
curl -s -X POST https://api.plungeai.com/v1/triggers/hooks \
  -H "Authorization: Bearer $PLUNGE_API_KEY" -H 'Content-Type: application/json' \
  -d '{"schedule_id":"<id>","url":"https://example.com/ocean-hook","events":["run.finished","run.failed"]}'
```

The response carries the hook `id` and a `secret`, shown once. The URL must be https, public, with no credentials in it, and it is re-checked before every delivery. A schedule has at most 10 hooks.

| Event | When |
|---|---|
| `run.started` | The run began. |
| `run.finished` | The run completed. |
| `run.failed` | The run failed, including an agent refusal that the history records as failed. |
| `run.needs_approval` | The run stopped on a held action. |

The body is `{event, delivery_id, timestamp, job_id, job_name, workflow_id, execution_id, run_id, status, result_summary?, error?}`. `execution_id` joins one run's events.

Each delivery carries three headers: `X-Ocean-Delivery-Id`, `X-Ocean-Timestamp` (unix seconds) and `X-Ocean-Signature`, which is `sha256=` plus the hex HMAC-SHA256 of `<delivery id>.<timestamp>.<raw body>` under the secret. Recompute it over the raw body, compare in constant time, and reject an old timestamp:

```javascript
import { createHmac, timingSafeEqual } from 'node:crypto'

export function verify(secret, headers, rawBody) {
  const id = headers['x-ocean-delivery-id']
  const ts = headers['x-ocean-timestamp']
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
  const want = 'sha256=' + createHmac('sha256', secret).update(`${id}.${ts}.${rawBody}`).digest('hex')
  const got = String(headers['x-ocean-signature'] ?? '')
  return got.length === want.length && timingSafeEqual(Buffer.from(got), Buffer.from(want))
}
```

- **Best effort.** One retry after a second, only on a network error, a timeout (10 seconds), a 5xx, a 408 or a 429. Any other answer is final. The retry sends the identical body, id and signature, so dedupe on `X-Ocean-Delivery-Id`. Redirects are never followed. A hook failure never fails or delays the run.
- **Scope.** Scheduler-path runs only: cron, heartbeat, run now and triggers. An ad-hoc run from Studio or the API emits nothing.
- **No ordering.** Events are sent as they happen, each independently. Order by `timestamp` and join on `execution_id`.
- **One-time jobs.** An `@once` job deletes itself when its run ends, and its hooks with it. The run's last event is still delivered.
