Webhooks
The framework provides webhooks that allow external systems to start workflows and send signals dynamically. This feature enables seamless integration with external services, APIs, and automation tools.
Enabling Webhooks
To enable webhooks, register the webhook routes in your application’s routes file (routes/web.php or routes/api.php):
use Workflow\V2\Webhooks;
Webhooks::routes([
App\Workflows\OrderWorkflow::class,
'manual-invoice' => App\Workflows\InvoiceWorkflow::class,
]);
Pass the explicit map of workflow classes you want to expose. Each alias becomes the public route segment, and each workflow class must carry a stable durable type key via #[Type(...)] or a registered entry in workflows.v2.types.workflows. See the Explicit Command And Query Webhooks section for the full route matrix.
Visibility Metadata
When you register routes through Workflow\V2\Webhooks::routes(...), the start route also accepts a reserved visibility object. Those fields are stored as workflow visibility metadata and are not passed to the workflow handle() method.
curl -X POST "https://example.com/webhooks/start/order-workflow" \
-H "Content-Type: application/json" \
-d '{
"workflow_id": "order-123",
"orderId": 123,
"visibility": {
"business_key": "order-123",
"labels": {
"tenant": "acme",
"region": "us-east"
},
"memo": {
"customer": {
"id": 42,
"name": "Taylor"
},
"source": "checkout"
}
}
}'
business_key and visibility.labels are copied onto the instance, run, run summary, typed start history, selected-run detail, and history export. Waterline can filter list screens by those fields with exact-match query parameters.
visibility.memo is copied onto the instance, run, typed start history, selected-run detail, and history export, and later continueAsNew() runs inherit the same memo by default. It must be a JSON object at the top level, with nested scalars, null, arrays, or objects. memo is returned-only metadata, not a list-filter or run-summary search field.
Webhook Authentication
By default, webhooks don't require authentication, but you can configure one of several strategies in config/workflows.php.
Important: If webhook URLs are shared with external parties or exposed publicly, enable authentication (token or HMAC signature) to prevent unauthorized access.
Authentication Methods
It supports:
- No Authentication (none)
- Token-based Authentication (token)
- HMAC Signature Verification (signature)
- Custom Authentication (custom)
Token Authentication
For token authentication, webhooks require a valid API token in the request headers. The default header is Authorization but you can change this in the configuration settings.
Example Request
curl -X POST "https://example.com/webhooks/start/order-workflow" \
-H "Content-Type: application/json" \
-H "Authorization: your-api-token" \
-d '{"orderId": 123}'
HMAC Signature Authentication
For HMAC authentication, it verifies requests using a secret key. The default header is X-Signature but this can also be changed.
Example Request
BODY='{"orderId": 123}'
SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "your-secret-key" | awk '{print $2}')
curl -X POST "https://example.com/webhooks/start/order-workflow" \
-H "Content-Type: application/json" \
-H "X-Signature: $SIGNATURE" \
-d "$BODY"
Custom Authentication
To use a custom authenticator, create a class that implements the WebhookAuthenticator interface:
use Illuminate\Http\Request;
use Workflow\Auth\WebhookAuthenticator;
class CustomAuthenticator implements WebhookAuthenticator
{
public function validate(Request $request): Request
{
$allow = true;
if ($allow) {
return $request;
} else {
abort(401, 'Unauthorized');
}
}
}
Then configure it in config/workflows.php:
'webhook_auth' => [
'method' => 'custom',
'custom' => [
'class' => App\Your\CustomAuthenticator::class,
],
],
The validate() method should return the Request if valid, or call abort(401) if unauthorized.
Configuring Webhook Routes
By default, webhooks are accessible under /webhooks. You can customize the route path in config/workflows.php:
'webhooks_route' => 'workflows',
After this change, webhooks will be accessible under:
POST /workflows/start/order-workflow
POST /workflows/signal/order-workflow/{workflowId}/mark-as-shipped
POST /workflows/instances/{workflowId}/queries/{query}
POST /workflows/instances/{workflowId}/runs/{runId}/queries/{query}
POST /workflows/instances/{workflowId}/signals/{signal}
POST /workflows/instances/{workflowId}/runs/{runId}/signals/{signal}
POST /workflows/instances/{workflowId}/repair
POST /workflows/instances/{workflowId}/cancel
POST /workflows/instances/{workflowId}/terminate
GET /workflows/instances/{workflowId}/describe
POST /workflows/instances/{workflowId}/runs/{runId}/repair
POST /workflows/instances/{workflowId}/runs/{runId}/cancel
POST /workflows/instances/{workflowId}/runs/{runId}/terminate
GET /workflows/instances/{workflowId}/runs/{runId}/describe
GET /workflows/workflow-tasks/poll
GET /workflows/activity-tasks/poll
POST /workflows/control-plane/start
Explicit Command And Query Webhooks
Durable start, signal, update, repair, cancel, and terminate commands are exposed over HTTP, plus replay-safe query routes for current-run and selected-run reads.
You register an explicit alias map and route only the workflows you want to expose.
use Workflow\V2\Webhooks;
Webhooks::routes([
App\Workflows\OrderWorkflow::class,
'manual-invoice' => App\Workflows\InvoiceWorkflow::class,
]);
When you register a workflow class directly, it must define a stable type key with #[Type(...)] or be registered under workflows.v2.types.workflows:
use Workflow\V2\Attributes\Type;
use Workflow\V2\Workflow;
#[Type('order-workflow')]
class OrderWorkflow extends Workflow
{
public function handle(int $orderId)
{
// ...
}
}
Equivalent config registration:
// config/workflows.php
'v2' => [
'types' => [
'workflows' => [
'order-workflow' => App\Workflows\OrderWorkflow::class,
],
],
],
The resulting start route is:
POST /webhooks/start/order-workflow
Example request:
curl -X POST "https://example.com/webhooks/start/order-workflow" \
-H "Content-Type: application/json" \
-d '{"workflow_id":"order-123","orderId":123}'
The workflow_id is the opaque public workflow instance id. It is not a run id and should be treated as an opaque string.
Caller-supplied workflow_id values must be non-empty URL-safe strings up to 191 characters using only letters, numbers, ., _, -, and :. Blank, overlong, or unsupported-character ids are rejected at webhook validation time with HTTP 422.
The same durable type map is also the current worker fallback when a stored workflow class name drifts after a refactor. If you keep order-workflow stable and repoint the config registration to the new class, queued v2 work can still resolve the durable type key even when the original stored PHP class name is no longer loadable.
You can also request explicit duplicate-start behavior:
curl -X POST "https://example.com/webhooks/start/order-workflow" \
-H "Content-Type: application/json" \
-d '{"workflow_id":"order-123","orderId":123,"on_duplicate":"return_existing_active"}'
Supported on_duplicate values are:
reject_duplicatereturn_existing_active
All command webhooks return the same JSON envelope:
{
"outcome": "started_new",
"workflow_id": "order-123",
"run_id": "01J...",
"command_id": "01J...",
"workflow_type": "order-workflow",
"command_status": "accepted",
"command_source": "webhook",
"rejection_reason": null
}
Accepted response when a new run is created:
{
"outcome": "started_new",
"workflow_id": "order-123",
"run_id": "01J...",
"command_id": "01J...",
"workflow_type": "order-workflow",
"command_status": "accepted",
"command_source": "webhook",
"rejection_reason": null
}
Accepted response when the caller requested reuse of the existing active run:
{
"outcome": "returned_existing_active",
"workflow_id": "order-123",
"run_id": "01J...",
"command_id": "01J...",
"workflow_type": "order-workflow",
"command_status": "accepted",
"command_source": "webhook",
"rejection_reason": null
}
Rejected duplicate-start response:
{
"outcome": "rejected_duplicate",
"workflow_id": "order-123",
"run_id": "01J...",
"requested_run_id": null,
"resolved_run_id": "01J...",
"command_id": "01J...",
"workflow_type": "order-workflow",
"command_status": "rejected",
"command_source": "webhook",
"rejection_reason": "instance_already_started"
}
Response fields:
workflow_idis the public workflow instance id.run_idis the first active run created by the accepted start command.requested_run_idis only set when the caller explicitly addressed one selected run.resolved_run_idis the run the engine actually resolved for this command; for instance-targeted starts it matchesrun_id.command_idis the durable start-command id.command_sourceiswebhookfor the webhook routes.- instance-targeted signal, update, repair, cancel, and terminate routes resolve the newest durable run for that instance instead of trusting only the mutable current-run pointer, so continue-as-new chains stay addressable through the same public id even if that column drifts.
- run-targeted command routes pin one selected run under the same public instance id and reject with
target_scope = run,outcome = rejected_not_current, andrejection_reason = selected_run_not_currentif that run is no longer current; in that caserun_idandrequested_run_idstay on the historical selection whileresolved_run_idpoints at the current run that callers should address next. - payload keys are matched to the workflow
handle()parameter names in declaration order. handle()is the canonical start contract. Older workflows that still implementexecute()continue to load through the compatibility path, but new code should usehandle()only.- mixed
handle()/execute()workflow inheritance is rejected with HTTP422validation errors before a run is created. - missing required payload keys are rejected with HTTP
422validation errors instead of being silently dropped. - blank, overlong, or non-route-safe
workflow_idvalues are rejected with HTTP422validation errors instead of falling through duplicate-start handling. - invalid
on_duplicatevalues are rejected with HTTP422validation errors. on_duplicate = return_existing_activereturns HTTP200when the current active run is reused.- duplicate starts that are not reused return HTTP
409withoutcome = rejected_duplicate,command_status = rejected, andrejection_reason = instance_already_started. - the durable
workflow_commandsrow also stores webhook ingress context separately from business arguments, including the caller label, auth method or outcome, request route name, request path, and a request fingerprint derived from the normalized payload plus selected request headers such asX-Request-IdandX-Correlation-Id
Current HTTP response matrix for the start webhook:
202withoutcome = started_newwhen a new run is created200withoutcome = returned_existing_activewhen the existing active run is reused409withoutcome = rejected_duplicatewhen the duplicate start is rejected401for webhook auth failure404for an unknown alias422for payload validation failure
Activity Task And Attempt Webhooks
An authenticated HTTP/JSON worker bridge is available for activity execution:
POST /webhooks/activity-tasks/{taskId}/claim
GET /webhooks/activity-attempts/{attemptId}
POST /webhooks/activity-attempts/{attemptId}/heartbeat
POST /webhooks/activity-attempts/{attemptId}/complete
POST /webhooks/activity-attempts/{attemptId}/fail
These routes use the same workflows.webhook_auth policy as the other webhook routes. They are a bridge for workers that already know a durable taskId or attemptId; they are not a hosted long-poll or task-discovery service.
The claim route accepts one optional field:
{
"lease_owner": "payments-worker-1"
}
Successful claims return the durable activity identity plus the codec-tagged stored argument payload:
{
"claimed": true,
"task_id": "01J...",
"workflow_instance_id": "order-123",
"workflow_run_id": "01J...",
"activity_execution_id": "01J...",
"activity_attempt_id": "01J...",
"attempt_number": 1,
"activity_type": "charge-card",
"activity_class": "App\\Workflows\\Activities\\ChargeCardActivity",
"idempotency_key": "01J...",
"payload_codec": "avro",
"arguments": "{\"amount\":1099}",
"retry_policy": {
"max_attempts": 3,
"backoff_seconds": 10
},
"connection": "redis",
"queue": "payments",
"lease_owner": "payments-worker-1",
"lease_expires_at": "2026-04-11T12:00:00.000000Z",
"reason": null,
"reason_detail": null,
"retry_after_seconds": null,
"backend_error": null,
"compatibility_reason": null
}
Unsuccessful claims return claimed = false with the same task_id plus one of the current public reasons: task_not_found, task_not_due, task_not_ready, backend_unavailable, compatibility_blocked, or task_not_claimable. task_not_claimable groups internal drift cases where the durable task can no longer produce a claimable activity attempt; in that case reason_detail further narrows the condition to task_not_activity, activity_execution_missing, or workflow_run_missing. For the other public reasons, reason_detail is null. When retry_after_seconds is present, the webhook also sets the HTTP Retry-After header.
Current HTTP response matrix for the activity-task claim webhook:
200withclaimed = truewhen the task lease is created401for webhook auth failure404withreason = task_not_found409for other unclaimed states such astask_not_due,task_not_ready,backend_unavailable,compatibility_blocked, ortask_not_claimable422whenlease_owneris present but is not a non-empty string up to 255 characters
Use GET /webhooks/activity-attempts/{attemptId} when the worker wants the current attempt status without renewing the lease. Use POST /webhooks/activity-attempts/{attemptId}/heartbeat to renew the lease and append typed heartbeat history. Both routes return the same structured stop contract as Workflow\V2\ActivityTaskBridge::status() and heartbeatStatus():
{
"can_continue": true,
"cancel_requested": false,
"reason": null,
"heartbeat_recorded": true,
"workflow_instance_id": "order-123",
"workflow_run_id": "01J...",
"workflow_task_id": "01J...",
"activity_execution_id": "01J...",
"activity_attempt_id": "01J...",
"attempt_number": 1,
"run_status": "running",
"activity_status": "running",
"attempt_status": "running",
"task_status": "leased",
"lease_owner": "payments-worker-1",
"lease_expires_at": "2026-04-11T12:00:30.000000Z",
"last_heartbeat_at": "2026-04-11T12:00:00.000000Z"
}
Heartbeat requests may include the same bounded progress object documented in Heartbeats. Invalid progress payloads reject with HTTP 422. A missing attempt id returns HTTP 404; a closed, stale, cancelled, or terminated attempt still returns HTTP 200 with can_continue = false, cancel_requested, and a concrete reason such as run_cancelled, run_terminated, stale_attempt, or task_not_leased.
Complete requests accept an optional top-level result field:
{
"result": {
"confirmation": "captured"
}
}
Fail requests require a top-level failure field. It can be a message string or a structured payload:
{
"failure": {
"type": "payments.gateway-timeout",
"class": "RuntimeException",
"message": "Gateway timeout"
}
}
Complete and fail responses return:
{
"recorded": true,
"reason": null,
"next_task_id": "01J..."
}
Current HTTP response matrix for the activity-attempt completion and failure webhooks:
200when the attempt outcome is durably recorded401for webhook auth failure404withreason = attempt_not_found409when the attempt is stale, already closed, or otherwise ignored by the durable recorder422when the failure payload is missing or invalid
Workflow Task Webhooks
An authenticated HTTP/JSON bridge is also available for workflow-task execution, matching the activity-task bridge surface:
POST /webhooks/workflow-tasks/{taskId}/claim
GET /webhooks/workflow-tasks/{taskId}/history
POST /webhooks/workflow-tasks/{taskId}/execute
POST /webhooks/workflow-tasks/{taskId}/complete
POST /webhooks/workflow-tasks/{taskId}/fail
POST /webhooks/workflow-tasks/{taskId}/heartbeat
These routes use the same workflows.webhook_auth policy as the other webhook routes. They bridge an external worker that already knows a durable taskId; they are not a hosted long-poll or task-discovery service.
The claim route accepts one optional field:
{
"lease_owner": "server-worker-1"
}
Successful claims return the durable workflow identity and lease metadata:
{
"claimed": true,
"task_id": "01J...",
"workflow_run_id": "01J...",
"workflow_instance_id": "order-123",
"workflow_type": "order-workflow",
"workflow_class": "App\\Workflows\\OrderWorkflow",
"payload_codec": "avro",
"connection": "redis",
"queue": "default",
"compatibility": null,
"lease_owner": "server-worker-1",
"lease_expires_at": "2026-04-12T12:05:00.000000Z",
"reason": null,
"reason_detail": null
}
Unsuccessful claims return claimed = false with a public reason such as task_not_found, task_not_claimable, run_closed, backend_unavailable, or compatibility_blocked.
Current HTTP response matrix for the workflow-task claim webhook:
200withclaimed = truewhen the task lease is created401for webhook auth failure404withreason = task_not_found409for other unclaimed states422whenlease_owneris present but is not a non-empty string up to 255 characters
The history route returns the full typed history event list, run metadata, and serialized arguments for the task. An external worker uses this payload to replay the workflow:
{
"task_id": "01J...",
"workflow_run_id": "01J...",
"workflow_instance_id": "order-123",
"workflow_type": "order-workflow",
"payload_codec": "avro",
"arguments": "{\"orderId\":123}",
"run_status": "pending",
"last_history_sequence": 2,
"history_events": [
{
"id": "01J...",
"sequence": 1,
"event_type": "StartAccepted",
"payload": {},
"workflow_task_id": null,
"workflow_command_id": "01J...",
"recorded_at": "2026-04-12T12:00:00.000000Z"
}
]
}
Returns HTTP 404 with reason = task_not_found when the task does not exist or is not a workflow task.
The execute route claims and executes a workflow task in-process using the package executor. This is the recommended HTTP path when the caller has the package installed:
{
"executed": true,
"task_id": "01J...",
"workflow_run_id": "01J...",
"run_status": "waiting",
"next_task_id": "01J...",
"reason": null
}
Returns HTTP 409 when the task cannot be claimed or execution fails.
The complete route applies replay results from an external worker. The request body must include a commands array as documented in the Microservices section:
{
"commands": [
{"type": "schedule_activity", "activity_type": "charge-card"},
{"type": "start_timer", "delay_seconds": 300}
]
}
Current HTTP response matrix for the workflow-task complete webhook:
200when commands are applied successfully401for webhook auth failure404withreason = task_not_found409when the task is not leased, the run is already closed, or the commands cannot be applied422when thecommandsarray is missing, empty, or contains invalid command objects
The fail route records a workflow task failure. The request body must include a failure field as a string or object:
{
"failure": "Worker crashed during replay"
}
Returns HTTP 404 with reason = task_not_found when the task does not exist.
The heartbeat route extends the lease on a claimed workflow task:
{
"renewed": true,
"task_id": "01J...",
"lease_expires_at": "2026-04-12T12:10:00.000000Z",
"run_status": "running",
"task_status": "leased",
"reason": null
}
Returns HTTP 404 with reason = task_not_found when the task does not exist. Returns HTTP 409 with reason = task_not_leased when the task is not in leased status.
Signal Command Webhooks
Both instance-targeted and run-targeted signal commands are available:
POST /webhooks/instances/{workflowId}/signals/{signal}
POST /webhooks/instances/{workflowId}/runs/{runId}/signals/{signal}
workflowId is the public workflow instance id. The instance-targeted route resolves the current active run at apply time. The run-targeted route also takes one selected runId and rejects if that selected run is historical. The signal name comes from the route parameter. The request body currently accepts one optional top-level field:
{
"arguments": ["Taylor"]
}
The arguments field must be an array. When it is omitted, a workflow waiting with await('signal-name') resumes with true.
The targeted workflow class must also declare the signal name with #[Workflow\V2\Attributes\Signal('...')]; undeclared names are rejected durably instead of being buffered blindly.
Signal example:
curl -X POST "https://example.com/webhooks/instances/order-123/signals/approved-by" \
-H "Content-Type: application/json" \
-d '{"arguments":["Taylor"]}'
Accepted signal response:
{
"outcome": "signal_received",
"workflow_id": "order-123",
"run_id": "01J...",
"requested_run_id": null,
"resolved_run_id": "01J...",
"command_id": "01J...",
"command_sequence": 2,
"target_scope": "instance",
"workflow_type": "order-workflow",
"command_status": "accepted",
"command_source": "webhook",
"rejection_reason": null
}
Accepted and rejected signal commands also get one first-class durable workflow_signal_records lifecycle row linked back to the originating command. Signal command responses still return the command id as the HTTP correlation id; Waterline selected-run detail and history exports expose the lifecycle row as signals[*].id, together with the signal name, signal_wait_id, command sequence, workflow sequence once applied, status, outcome, validation errors, and stored arguments.
Rejected response when the instance exists but has not started a run yet:
{
"outcome": "rejected_not_started",
"workflow_id": "order-123",
"run_id": null,
"command_id": "01J...",
"target_scope": "instance",
"workflow_type": "order-workflow",
"command_status": "rejected",
"rejection_reason": "instance_not_started"
}
Rejected response when the current run is already closed:
{
"outcome": "rejected_not_active",
"workflow_id": "order-123",
"run_id": "01J...",
"command_id": "01J...",
"target_scope": "instance",
"workflow_type": "order-workflow",
"command_status": "rejected",
"rejection_reason": "run_not_active"
}
Rejected response when the route targets an undeclared signal name:
{
"outcome": "rejected_unknown_signal",
"workflow_id": "order-123",
"run_id": "01J...",
"command_id": "01J...",
"target_scope": "instance",
"workflow_type": "order-workflow",
"command_status": "rejected",
"rejection_reason": "unknown_signal"
}
Rejected response when a run-targeted signal route addresses a historical run:
{
"outcome": "rejected_not_current",
"workflow_id": "order-123",
"run_id": "01J...",
"requested_run_id": "01J...",
"resolved_run_id": "01J...",
"command_id": "01J...",
"target_scope": "run",
"workflow_type": "order-workflow",
"command_status": "rejected",
"rejection_reason": "selected_run_not_current"
}
Current HTTP response matrix for the signal webhook:
202withoutcome = signal_receivedwhen the signal command is accepted401for webhook auth failure404for an unknown workflow instance id404withoutcome = rejected_unknown_signalwhen the workflow does not declare that signal name; when the run already carries a typedWorkflowStartedcontract snapshot, or when an olderWorkflowStartedevent can be backfilled on first compatible intake, that rejection no longer depends on reflecting the live workflow class first409withoutcome = rejected_not_startedwhen the instance has no current run yet409withoutcome = rejected_not_activewhen the targeted current run is already closed409withoutcome = rejected_not_currentwhen the run-targeted route addresses a historical selected run422whenargumentsis present but not an array
Signal-With-Start Webhooks
One instance-targeted linked-intake route starts a new run or reuses the current active run before recording a signal:
POST /webhooks/start/{alias}/signals/{signal}
alias is the webhook workflow alias you registered in Workflow\V2\Webhooks::routes([...]). signal is the durable signal name declared by #[Signal(...)].
The request body combines the normal start fields plus one reserved signal_arguments field for the attached signal payload:
{
"workflow_id": "order-123",
"signal_arguments": ["Taylor"],
"visibility": {
"business_key": "order-123"
}
}
signal_arguments must be an array. The route defaults on_duplicate to return_existing_active, and rejects any other duplicate policy value for this linked-intake route.
Accepted response when the route starts a new run:
{
"outcome": "signal_received",
"workflow_id": "order-123",
"run_id": "01J...",
"requested_run_id": null,
"resolved_run_id": "01J...",
"command_id": "01J...",
"command_sequence": 2,
"target_scope": "instance",
"workflow_type": "order-workflow",
"command_status": "accepted",
"command_source": "webhook",
"start_command_id": "01J...",
"start_command_sequence": 1,
"start_outcome": "started_new",
"start_command_status": "accepted",
"intake_group_id": "01J...",
"rejection_reason": null
}
When the workflow instance already has an active current run, the same route returns start_outcome = returned_existing_active and keeps the signal command as the top-level command_* response object. The linked start and signal commands share the same intake_group_id, request route metadata, and request correlation headers, and the runtime records both commands in one transaction before the worker can run the first user workflow step.
Selected-run detail and history export freeze that same compound intake under linked_intakes[*]. The durable authority is the shared workflow_commands.context.intake.group_id plus mode recorded on each accepted command row. For the signal_with_start mode, the grouped row exposes start_command_*, the non-start primary_command_*, complete, missing_expected_command_types, and the ordered nested commands[*] snapshots.
If the signal name is unknown or the signal payload fails durable contract validation, the runtime rejects the signal command before creating a new run. In that case the response leaves run_id, start_command_id, and start_outcome as null.
Current HTTP response matrix for the signal-with-start webhook:
202withoutcome = signal_receivedandstart_outcome = started_newwhen the request creates a run and records the signal202withoutcome = signal_receivedandstart_outcome = returned_existing_activewhen the request reuses the current active run and records the signal401for webhook auth failure404for an unknown webhook workflow alias404withoutcome = rejected_unknown_signalwhen the workflow does not declare that signal name422withoutcome = rejected_invalid_argumentswhensignal_argumentsis an array but does not satisfy the durable signal contract422whensignal_argumentsis present but not an array, or whenon_duplicateis set to any value other thanreturn_existing_active
Query Webhooks
Both instance-targeted and run-targeted query routes are available:
POST /webhooks/instances/{workflowId}/queries/{query}
POST /webhooks/instances/{workflowId}/runs/{runId}/queries/{query}
workflowId is the public workflow instance id. The instance-targeted route resolves the newest durable run for that instance after refresh. The run-targeted route pins one selected runId, including historical or already-closed runs, because queries are read-only replay operations.
query is the public query target declared by #[QueryMethod]. When the workflow definition is still loadable, the route also accepts the underlying PHP method name, but successful responses normalize query_name back to the durable public target.
The request body accepts either a positional arguments list or a named arguments map keyed by the declared query parameters:
{
"arguments": ["start"]
}
{
"arguments": {
"prefix": "start"
}
}
Unlike the mutating webhook routes, query webhooks do not append a durable command row. They replay the selected run and return a serialized result immediately.
Query example:
curl -X POST "https://example.com/webhooks/instances/order-123/queries/events-starting-with" \
-H "Content-Type: application/json" \
-d '{"arguments":{"prefix":"start"}}'
Accepted query response:
{
"query_name": "events-starting-with",
"workflow_id": "order-123",
"run_id": "01J...",
"target_scope": "instance",
"result": "i:1;"
}
Rejected response when the query arguments do not match the declared contract:
{
"query_name": "events-starting-with",
"workflow_id": "order-123",
"run_id": "01J...",
"target_scope": "instance",
"message": "Workflow query [events-starting-with] received invalid arguments.",
"validation_errors": {
"prefix": [
"The prefix argument is required."
]
}
}
Rejected response when the selected run's workflow definition can no longer be replayed:
{
"query_name": "events-starting-with",
"workflow_id": "order-123",
"run_id": "01J...",
"target_scope": "instance",
"blocked_reason": "workflow_definition_unavailable",
"message": "Workflow 01J... [order-123] cannot execute query [events-starting-with] because the workflow definition is unavailable for durable type [order-workflow]."
}
Current HTTP response matrix for the query webhook:
200with a serializedresultwhen the query replay succeeds401for webhook auth failure404for an unknown workflow instance id or selected run id409when the selected instance has not started yet, when the requested query is not declared on that selected run, or when replay is blocked byblocked_reason = workflow_definition_unavailable422when the declared query contract rejects the providedarguments
Update Command Webhooks
Instance-targeted and run-targeted update POST routes plus durable lifecycle lookup routes are available:
POST /webhooks/instances/{workflowId}/updates/{update}
POST /webhooks/instances/{workflowId}/runs/{runId}/updates/{update}
GET /webhooks/instances/{workflowId}/updates/{updateId}
GET /webhooks/instances/{workflowId}/runs/{runId}/updates/{updateId}
workflowId is the public workflow instance id. update is the durable update name declared by #[UpdateMethod]. updateId is the durable lifecycle id returned by update responses. The instance-targeted POST route resolves the current active run at apply time. The run-targeted POST route also takes one selected runId and rejects if that selected run is historical. The instance-targeted GET route reads the stored lifecycle for that workflow instance, while the run-targeted GET route narrows lookup to the selected run.
If you use #[UpdateMethod('mark-approved')], the public webhook target is mark-approved even if the underlying PHP method is named differently.
The request body accepts either a positional arguments list or a named arguments map keyed by the declared update parameters:
{
"arguments": [true, "api"]
}
{
"arguments": {
"approved": true
}
}
Set wait_for to accepted when the caller only needs the durable update command to be accepted and wants the workflow worker to apply the update later:
{
"arguments": {
"approved": true
},
"wait_for": "accepted"
}
When wait_for is omitted or set to completed, the webhook records the accepted update first, then waits up to the configured completion budget for the workflow worker to apply it. Override that budget per request with wait_timeout_seconds:
{
"arguments": {
"approved": true
},
"wait_timeout_seconds": 5
}
If the worker completes within that budget, the webhook returns the normal completed or failed update response. If the wait budget expires first, the webhook returns HTTP 202 with the still-open accepted lifecycle instead of blocking indefinitely. When wait_for is accepted, the webhook records the command row, the first-class update lifecycle row, and typed UpdateAccepted history, then schedules or re-dispatches a workflow task and returns HTTP 202 without waiting for application.
Every update POST and GET response includes the normal command fields plus update_id, update_name, update_status, workflow_sequence, accepted_at, applied_at, rejected_at, and closed_at. Accepted-but-open lifecycles return workflow_sequence = null, result = null, applied_at = null, rejected_at = null, and closed_at = null until the worker closes the lifecycle.
When a named map is accepted, the engine normalizes it into the declared parameter order before it appends typed UpdateAccepted or UpdateApplied history. Optional parameters also fill from their PHP defaults during that normalization step.
Every accepted or rejected update is backed by one durable workflow_updates row keyed by update_id, so callers can POST once and later GET the stored lifecycle without inferring state from generic command rows.
Update example:
curl -X POST "https://example.com/webhooks/instances/order-123/updates/mark-ready" \
-H "Content-Type: application/json" \
-d '{"arguments":{"approved":true}}'
Completed update response:
{
"outcome": "update_completed",
"workflow_id": "order-123",
"run_id": "01J...",
"command_id": "01J...",
"update_id": "01J...",
"command_sequence": 2,
"target_scope": "instance",
"workflow_type": "order-workflow",
"command_status": "accepted",
"command_source": "webhook",
"update_name": "mark-ready",
"update_status": "completed",
"workflow_sequence": 1,
"accepted_at": "2026-04-10T12:00:00.000000Z",
"applied_at": "2026-04-10T12:00:01.000000Z",
"rejected_at": null,
"closed_at": "2026-04-10T12:00:01.000000Z",
"wait_for": "completed",
"wait_timed_out": false,
"wait_timeout_seconds": 10,
"rejection_reason": null,
"validation_errors": [],
"result": {
"approved": true
},
"failure_id": null,
"failure_message": null
}
Accepted-only update response:
{
"outcome": null,
"workflow_id": "order-123",
"run_id": "01J...",
"command_id": "01J...",
"update_id": "01J...",
"command_sequence": 2,
"target_scope": "instance",
"workflow_type": "order-workflow",
"command_status": "accepted",
"command_source": "webhook",
"update_name": "mark-ready",
"update_status": "accepted",
"workflow_sequence": null,
"accepted_at": "2026-04-10T12:00:00.000000Z",
"applied_at": null,
"rejected_at": null,
"closed_at": null,
"wait_for": "accepted",
"wait_timed_out": false,
"wait_timeout_seconds": null,
"rejection_reason": null,
"validation_errors": [],
"result": null,
"failure_id": null,
"failure_message": null
}
Lifecycle lookup example:
curl "https://example.com/webhooks/instances/order-123/updates/01J..." \
-H "Content-Type: application/json"
Lookup response while the update is still open:
{
"outcome": null,
"workflow_id": "order-123",
"run_id": "01J...",
"command_id": "01J...",
"update_id": "01J...",
"command_sequence": 2,
"target_scope": "instance",
"workflow_type": "order-workflow",
"command_status": "accepted",
"command_source": "webhook",
"update_name": "mark-ready",
"update_status": "accepted",
"workflow_sequence": null,
"accepted_at": "2026-04-10T12:00:00.000000Z",
"applied_at": null,
"rejected_at": null,
"closed_at": null,
"wait_for": "status",
"wait_timed_out": false,
"wait_timeout_seconds": null,
"rejection_reason": null,
"validation_errors": [],
"result": null,
"failure_id": null,
"failure_message": null
}
Lookup response after the lifecycle closes:
{
"outcome": "update_completed",
"workflow_id": "order-123",
"run_id": "01J...",
"command_id": "01J...",
"update_id": "01J...",
"command_sequence": 2,
"target_scope": "instance",
"workflow_type": "order-workflow",
"command_status": "accepted",
"command_source": "webhook",
"update_name": "mark-ready",
"update_status": "completed",
"workflow_sequence": 1,
"accepted_at": "2026-04-10T12:00:00.000000Z",
"applied_at": "2026-04-10T12:00:01.000000Z",
"rejected_at": null,
"closed_at": "2026-04-10T12:00:01.000000Z",
"wait_for": "status",
"wait_timed_out": false,
"wait_timeout_seconds": null,
"rejection_reason": null,
"validation_errors": [],
"result": {
"approved": true
},
"failure_id": null,
"failure_message": null
}
Lookup routes do not wait for execution. They only read the stored durable lifecycle, so they always return wait_for = status, wait_timed_out = false, and wait_timeout_seconds = null.
When the selected run already has an earlier accepted signal waiting to be applied, the later POST rejects as rejected_pending_signal instead of running that workflow task inline on the caller path. Invalid named or positional payloads reject as rejected_invalid_arguments with validation_errors. Historical run-targeted POST routes reject as rejected_not_current.
Current HTTP response matrix for the update POST routes:
200withoutcome = update_completedwhen the update command is accepted and completed202withupdate_status = acceptedwhenwait_for = accepteddurably accepts the update command and leaves application to the workflow worker202withupdate_status = accepted,wait_for = completed, andwait_timed_out = truewhen the webhook waited up towait_timeout_secondsor the configured default and the worker still had not closed the update lifecycle401for webhook auth failure404for an unknown workflow instance id404withoutcome = rejected_unknown_updatefor an unknown durable update name; when the run already carries a typedWorkflowStartedcontract snapshot, or when an olderWorkflowStartedevent can be backfilled on first compatible intake, that rejection can come from durable run metadata before any live-definition fallback409withoutcome = rejected_not_startedwhen the instance has no current run yet409withoutcome = rejected_not_activewhen the current run is already closed409withoutcome = rejected_not_currentwhen the run-targeted route addresses a historical selected run409withoutcome = rejected_pending_signalwhen an earlier accepted signal still has to be applied before the update can run422withoutcome = rejected_invalid_argumentswhen the update payload is an array but does not satisfy the declared update contract, including missing arguments, unknown arguments, type mismatches, or nullability violations422withoutcome = update_failedwhen the update body throws422whenargumentsis present but not an array; that request-shape failure is rejected before a durable command row is created
Current HTTP response matrix for the update GET lookup routes:
200once the lifecycle is closed andupdate_statusiscompleted,failed, orrejected202whileupdate_status = accepted401for webhook auth failure404for an unknown workflow instance id, selected run id, orupdate_idwithin the requested scope
Repair And Terminal Command Webhooks
Both instance-targeted and run-targeted repair and terminal command routes are available:
POST /webhooks/instances/{workflowId}/repair
POST /webhooks/instances/{workflowId}/cancel
POST /webhooks/instances/{workflowId}/terminate
POST /webhooks/instances/{workflowId}/runs/{runId}/repair
POST /webhooks/instances/{workflowId}/runs/{runId}/cancel
POST /webhooks/instances/{workflowId}/runs/{runId}/terminate
workflowId here is the public workflow instance id. The instance routes always resolve the current active run. The run routes also take one selected runId and reject historical selections with the durable rejected_not_current outcome. The same workflows.webhook_auth configuration used by the start route also applies to these terminal command routes.
Repair example:
curl -X POST "https://example.com/webhooks/instances/order-123/repair" \
-H "Content-Type: application/json"
Cancel example:
curl -X POST "https://example.com/webhooks/instances/order-123/cancel" \
-H "Content-Type: application/json"
Terminate example:
curl -X POST "https://example.com/webhooks/instances/order-123/terminate" \
-H "Content-Type: application/json"
Accepted repair response when the runtime restores durable progress for the current run:
{
"outcome": "repair_dispatched",
"workflow_id": "order-123",
"run_id": "01J...",
"command_id": "01J...",
"target_scope": "instance",
"workflow_type": "order-workflow",
"command_status": "accepted",
"rejection_reason": null
}
Accepted repair response when the current run already has a healthy durable resume path:
{
"outcome": "repair_not_needed",
"workflow_id": "order-123",
"run_id": "01J...",
"command_id": "01J...",
"target_scope": "instance",
"workflow_type": "order-workflow",
"command_status": "accepted",
"rejection_reason": null
}
Accepted cancel response:
{
"outcome": "cancelled",
"workflow_id": "order-123",
"run_id": "01J...",
"command_id": "01J...",
"target_scope": "instance",
"workflow_type": "order-workflow",
"command_status": "accepted",
"rejection_reason": null
}
Accepted terminate response:
{
"outcome": "terminated",
"workflow_id": "order-123",
"run_id": "01J...",
"command_id": "01J...",
"target_scope": "instance",
"workflow_type": "order-workflow",
"command_status": "accepted",
"rejection_reason": null
}
Rejected response when the instance exists but has not started a run yet:
{
"outcome": "rejected_not_started",
"workflow_id": "order-123",
"run_id": null,
"command_id": "01J...",
"target_scope": "instance",
"workflow_type": "order-workflow",
"command_status": "rejected",
"rejection_reason": "instance_not_started"
}
Rejected response when the current run is already closed:
{
"outcome": "rejected_not_active",
"workflow_id": "order-123",
"run_id": "01J...",
"command_id": "01J...",
"target_scope": "instance",
"workflow_type": "order-workflow",
"command_status": "rejected",
"rejection_reason": "run_not_active"
}
Rejected response when a run-targeted repair, cancel, or terminate route addresses a historical run:
{
"outcome": "rejected_not_current",
"workflow_id": "order-123",
"run_id": "01J...",
"requested_run_id": "01J...",
"resolved_run_id": "01J...",
"command_id": "01J...",
"target_scope": "run",
"workflow_type": "order-workflow",
"command_status": "rejected",
"rejection_reason": "selected_run_not_current"
}
Current HTTP response matrix for the repair, cancel, and terminate webhooks:
200withoutcome = repair_dispatchedwhen repair re-dispatches an overdue ready task, reclaims an expired lease, or recreates a missing durable task that has not started running yet, including accepted-update and accepted-signal workflow application tasks whose rows were lost before the command could be applied200withoutcome = repair_not_neededwhen the run already has a healthy durable resume path or when the selected run is already inside an in-flight activity with no task row200withoutcome = cancelledoroutcome = terminatedwhen the current run is closed successfully401for webhook auth failure404for an unknown workflow instance id409withoutcome = rejected_not_startedwhen the instance has no current run yet409withoutcome = rejected_not_activewhen the current run is already closed409withoutcome = rejected_not_currentwhen the run-targeted route addresses a historical selected run
Describe Webhooks
Both instance-targeted and run-targeted describe routes are available for inspecting workflow state over HTTP:
GET /webhooks/instances/{workflowId}/describe
GET /webhooks/instances/{workflowId}/runs/{runId}/describe
workflowId is the public workflow instance id. The instance-targeted route returns the current run's state. The run-targeted route describes a specific run by id, including historical or already-closed runs.
Describe is a read-only inspection operation. It does not create a durable command row and does not replay the workflow. It returns instance metadata, current or selected run state, summary fields, and action availability from committed projection data.
Describe example:
curl "https://example.com/webhooks/instances/order-123/describe"
Response for an active workflow:
{
"found": true,
"workflow_instance_id": "order-123",
"workflow_type": "order-workflow",
"workflow_class": "App\\Workflows\\OrderWorkflow",
"business_key": "order-123",
"run": {
"workflow_run_id": "01J...",
"run_number": 1,
"is_current_run": true,
"status": "waiting",
"status_bucket": "running",
"closed_reason": null,
"compatibility": "build-a",
"connection": "redis",
"queue": "default",
"started_at": "2026-04-12T12:00:00+00:00",
"closed_at": null,
"last_progress_at": "2026-04-12T12:00:01+00:00",
"wait_kind": "signal",
"wait_reason": "Waiting for signal [approved-by]"
},
"run_count": 1,
"actions": {
"can_signal": true,
"can_query": true,
"can_update": true,
"can_cancel": true,
"can_terminate": true
},
"reason": null
}
Response for a terminated workflow:
{
"found": true,
"workflow_instance_id": "order-123",
"workflow_type": "order-workflow",
"workflow_class": "App\\Workflows\\OrderWorkflow",
"business_key": "order-123",
"run": {
"workflow_run_id": "01J...",
"run_number": 1,
"is_current_run": true,
"status": "terminated",
"status_bucket": "failed",
"closed_reason": "terminated",
"compatibility": "build-a",
"connection": "redis",
"queue": "default",
"started_at": "2026-04-12T12:00:00+00:00",
"closed_at": "2026-04-12T12:01:00+00:00",
"last_progress_at": "2026-04-12T12:01:00+00:00",
"wait_kind": null,
"wait_reason": null
},
"run_count": 1,
"actions": {
"can_signal": false,
"can_query": false,
"can_update": false,
"can_cancel": false,
"can_terminate": false
},
"reason": null
}
Action availability reflects whether the operation can succeed right now: closed runs cannot accept commands, remote-only workflows cannot serve queries or updates locally, and non-current runs cannot receive signals, updates, cancellations, or terminations.
Run-targeted describe example:
curl "https://example.com/webhooks/instances/order-123/runs/01J.../describe"
The run-targeted response includes the same payload but is_current_run will be false when the selected run is historical, and all mutating actions will show false.
Response when the instance is not found:
{
"found": false,
"workflow_instance_id": "order-123",
"workflow_type": null,
"workflow_class": null,
"business_key": null,
"run": null,
"run_count": 0,
"actions": {
"can_signal": false,
"can_query": false,
"can_update": false,
"can_cancel": false,
"can_terminate": false
},
"reason": "instance_not_found"
}
Response when the instance exists but the selected run is not found:
{
"found": true,
"workflow_instance_id": "order-123",
"workflow_type": "order-workflow",
"workflow_class": "App\\Workflows\\OrderWorkflow",
"business_key": "order-123",
"run": null,
"run_count": 1,
"actions": {
"can_signal": false,
"can_query": false,
"can_update": false,
"can_cancel": false,
"can_terminate": false
},
"reason": "run_not_found"
}
Current HTTP response matrix for the describe webhooks:
200when the instance is found, whether the run is active, closed, or historical200when the instance is found but the selected run does not exist (reason = run_not_found)401for webhook auth failure404when the workflow instance id does not exist (reason = instance_not_found)
Task Poll Webhooks
Poll routes are available for discovering ready tasks by queue, connection, and compatibility criteria. These routes let an external consumer discover durable tasks without maintaining a separate task-discovery service.
GET /webhooks/workflow-tasks/poll
GET /webhooks/activity-tasks/poll
Both routes accept the same optional query parameters:
| Parameter | Type | Description |
|---|---|---|
connection | string | Filter tasks by queue connection |
queue | string | Filter tasks by queue name |
limit | integer | Maximum number of tasks to return (1–100, default 10) |
compatibility | string | Filter tasks by compatibility marker |
Workflow task poll example:
curl "https://example.com/webhooks/workflow-tasks/poll?connection=redis&queue=default&limit=5"
Response:
{
"tasks": [
{
"task_id": "01J...",
"workflow_run_id": "01J...",
"workflow_instance_id": "order-123",
"workflow_type": "order-workflow",
"workflow_class": "App\\Workflows\\OrderWorkflow",
"connection": "redis",
"queue": "default",
"compatibility": "build-a",
"available_at": "2026-04-12T12:00:00.000000Z"
}
]
}
Activity task poll example:
curl "https://example.com/webhooks/activity-tasks/poll?connection=redis&queue=payments"
Response:
{
"tasks": [
{
"task_id": "01J...",
"workflow_run_id": "01J...",
"workflow_instance_id": "order-123",
"activity_execution_id": "01J...",
"activity_type": "charge-card",
"activity_class": "App\\Workflows\\Activities\\ChargeCardActivity",
"connection": "redis",
"queue": "payments",
"compatibility": null,
"available_at": "2026-04-12T12:00:00.000000Z"
}
]
}
Poll routes only return tasks that are in ready status and whose available_at timestamp is not in the future. The response is always HTTP 200 with an empty tasks array when no matching tasks exist. The compatibility filter matches the exact marker stored on each task, which inherits from the run's compatibility marker at scheduling time.
Current HTTP response matrix for the task poll webhooks:
200withtasksarray (may be empty)401for webhook auth failure422when a query parameter has an invalid type
The typical server polling loop calls poll, claims the first returned task by id, fetches history (for workflow tasks) or arguments (from the claim payload for activity tasks), executes, and completes or fails the task. Poll is a discovery mechanism; the claim route remains the concurrency-safe lease acquisition path.
Control-Plane Start Webhook
A control-plane start route accepts a durable workflow type key directly, without requiring the workflow class to be locally resolvable. This is the preferred start path for an external consumer that drives workflows through type keys rather than PHP class aliases.
POST /webhooks/control-plane/start
The request body accepts:
| Field | Type | Required | Description |
|---|---|---|---|
workflow_type | string | yes | Durable workflow type key |
instance_id | string | no | Caller-supplied public workflow instance id |
arguments | string | no | Codec-tagged serialized arguments |
connection | string | no | Queue connection override |
queue | string | no | Queue name override |
business_key | string | no | Caller-supplied business key |
labels | object | no | Visibility labels |
memo | object | no | Non-indexed metadata |
duplicate_start_policy | string | no | reject_duplicate or return_existing_active |
Example request:
curl -X POST "https://example.com/webhooks/control-plane/start" \
-H "Content-Type: application/json" \
-d '{
"workflow_type": "order-workflow",
"instance_id": "order-123",
"arguments": "{\"orderId\":123}",
"connection": "redis",
"queue": "default",
"business_key": "order-123",
"labels": {"tenant": "acme"}
}'
Response for a newly started workflow:
{
"started": true,
"workflow_instance_id": "order-123",
"workflow_run_id": "01J...",
"workflow_type": "order-workflow",
"outcome": "started_new",
"task_id": "01J...",
"reason": null
}
When instance_id is omitted, the engine generates a ULID-based instance id automatically.
When the workflow type key maps to a locally resolvable class through workflows.v2.types.workflows config or a #[Type(...)] attribute, the full command-contract snapshot and routing are applied at start time. When the class is not locally available, the instance is created with the type key and explicit routing from options; the command-contract snapshot and definition fingerprint are deferred to the worker that claims the first task. This means an external consumer can start workflows for type keys that only the worker fleet can resolve.
Current HTTP response matrix for the control-plane start webhook:
202withoutcome = started_newwhen a new run is created200withoutcome = returned_existing_activewhen the existing active run is reused409withoutcome = rejected_duplicatewhen the duplicate start is rejected401for webhook auth failure422whenworkflow_typeis missing orinstance_idis invalid
The WorkflowControlPlane contract is also available as a container-resolvable singleton for programmatic use. Resolve Workflow\V2\Contracts\WorkflowControlPlane from the Laravel container to call start(), signal(), query(), update(), cancel(), terminate(), and describe() directly from PHP code, tests, or Artisan commands.
The webhook system covers explicit alias-based start intake plus both instance-targeted and run-targeted signal, update, query, describe, repair, cancel, and terminate command webhooks, task poll routes for workflow and activity tasks, and a control-plane start route for type-key-based workflow creation.