# Inbound Triggers

An external trigger starts a workflow after your plugin receives an event. That might be an HTTP webhook, a queue message, or another provider callback. Your plugin owns receiving and authenticating the event; Flowcrafter owns creating the run and executing the workflow.

![Webhook trigger settings for an inbound workflow endpoint](/documentation/flowcrafter/screenshots/webhook-trigger-settings.png)

:::note[At a glance]
- Implement `Is_Trigger` on a normal node handler, then register it through `flowcrafter_register_nodes`.
- Authenticate the sender and capture the payload before calling `External_Triggers::start()`.
- Core checks active status, registered trigger identity, launch validation, and duplicate deliveries. It logs and completes the trigger, then dispatches its children.
- `Supports_Test_Listen` adds generic builder test sessions. Your provider opens and closes its own listener; core owns pending-node transitions and scheduled cleanup.
:::

API Alchemist's Webhook Trigger is a visible reference for an inbound node: it starts an example workflow and exposes its inbound controls in the node window. The URL token visible in this capture was revoked afterward and must not be reused.

![Webhook Trigger starting a workflow](/documentation/flowcrafter/screenshots/node-webhook-trigger-canvas.png)

![Webhook Trigger settings with the captured URL token revoked after capture](/documentation/flowcrafter/screenshots/node-webhook-trigger-settings.png)

---

## Start a production run

Call the API after your provider has authenticated the event and resolved a saved workflow and trigger node. A request key identifies one delivery: send the same key on retries, and a new key for a genuinely new event.

```php
$response = \Flowcrafter\External_Triggers::start(
    $workflow,
    $node_id,
    [ 'event_id' => $event_id, 'body' => $captured_body ],
    'provider-event:' . $event_id
);
```

| Method | Purpose |
|---|---|
| `start(Workflow $workflow, string $node_id, array $payload, string $request_key): WP_REST_Response` | Start or deduplicate a production event. |
| `accept_test(int $run_id, string $node_id, array $payload): ?WP_REST_Response` | Capture one pending frozen test trigger, without fan-out. |
| `fail_test(int $run_id, string $node_id, Throwable $error, bool $claimed = false): void` | Claim and fail a pending test capture, or record a failure for a claim the caller already owns. |
| `cancel_test(int $run_id, string $node_id): void` | Cancel a still-pending test node without overwriting a completed or running capture. |

Production success returns `run_id` and `status: accepted`. A retry returns the first run's ID and `status: duplicate`. Invalid or inactive workflows return 422. The existing `Dispatch::TYPE_WEBHOOK_TRIGGER` marker is retained so retries still find older deliveries.

The trigger's `execute()` method is not called during production acceptance. Core records the supplied payload through `Engine::complete_trigger()` and fans out through the durable dispatch outbox. Test acceptance instead logs the output and completes only the selected trigger; the builder decides when to step downstream.

An empty array is a valid captured event. Re-running an external-trigger run preserves it just like a non-empty payload; only an absent payload is refused, such as when retention has removed the original node log.

:::caution[Authenticate before handing an event to core]
`External_Triggers` is an execution API, not an authentication layer. It does not inspect headers, signatures, body size, tokens, or HTTP methods. Your provider must reject invalid input before calling it. Choose a stable request key inside that trusted boundary; do not use a random UUID for every retry.
:::

---

## Open and close a test listener

Implement both methods of `Supports_Test_Listen`. The controller supplies the selected node from the test run's **frozen workflow** (the copy captured when testing began), including unsaved builder settings.

```php
public function prepare_test_listen( array $node, \Flowcrafter\Workflow_Run $run );
public function stop_test_listen( array $node, \Flowcrafter\Workflow_Run $run, string $listener_id ): void;
```

`prepare_test_listen()` returns a `WP_Error` or this generic shape. Put provider-specific display fields inside `data`, not at the top level:

```php
return [
    'listening'   => true,
    'listener_id' => $listener_id,
    'expires_at'  => gmdate( 'Y-m-d\TH:i:s\Z', time() + 300 ),
    'expires_in'  => 300,
    'data'        => [ 'webhook_url' => $provider_url ],
];
```

Core records `fc_test_session_{listener_id}` with the run ID, node ID, and expiry. It also records the current session for that run/node and schedules `flowcrafter_expire_test_session` with `wp_schedule_single_event()`. Provider routing tokens and subscriptions belong in provider-owned storage, not in these generic records.

`stop_test_listen()` must revoke only the requested listener generation. Compare its ID before removing a shared routing pointer, so delayed cleanup cannot delete a replacement listener. Core calls this method on Stop, expiry, or run cancellation, then cancels a still-pending node only if the session is still current. A failed stop callback is retried. Start and cleanup share a site-scoped lock; providers must not acquire that lock themselves.

When a test event arrives:

1. Resolve the provider's listener record and reject closed or expired generations.
2. Load the frozen test node, authenticate the event against its settings, and capture its payload.
3. Call `External_Triggers::accept_test($run_id, $node_id, $payload)`. `null` means the pending claim was unavailable or the test context was invalid; do not fan out or overwrite the node yourself.
4. Close the provider listener after receipt. Core session cleanup remains safe to call again.
5. If provider capture throws, call `fail_test()` without claiming a node yourself. An ordinary sender-authentication error should normally leave the listener open for a valid event.

`accept_test()` requires a registered `Is_Trigger` handler implementing `Supports_Test_Listen`, claims the pending node atomically, and records its output once. `fail_test()` cannot steal another request's running claim unless the caller explicitly passes `claimed: true` for a claim it owns.

---

## HTTP providers and routing tokens

Flowcrafter does not register a public inbound HTTP route. GC API Alchemist owns Webhook Trigger's endpoint, sender checks, payload normalization, idempotency-header selection, and token/listener transients. Its production and frozen-test evaluations are independent: a test failure must not replace a successful production response or prevent production dispatch.

`Has_Inbound_Token` remains useful for providers with token-bearing nodes. Core backfills tokens on save, indexes them for routing, and regenerates them when copying a workflow. If a provider is unavailable during a save, core keeps its existing indexed token for the same node/type so restoring and activating that provider restores routing without another workflow save.

`Handles_Inbound_Request` is an optional HTTP-provider contract. Neither it nor `Has_Inbound_Token` is required by `External_Triggers`. Non-HTTP providers can implement `Is_Trigger` and optionally `Supports_Test_Listen` without either HTTP contract.
