Overview
Webhooks provide HTTP endpoints to interact with your workflows from external systems. You can:
- Start new workflow instances
- Send signals to running workflows
- Integrate with third-party services (Stripe, GitHub, etc.)
Enabling Webhooks
Add the #[Webhook] attribute to your workflow class to expose HTTP endpoints:
use Workflow\Workflow;
use Workflow\Webhook;
use Workflow\SignalMethod;
#[Webhook]
class PaymentWorkflow extends Workflow
{
public function execute($amount, $customerId)
{
// Wait for payment confirmation
yield $this->awaitSignal('confirm');
yield $this->processPayment($amount, $customerId);
}
#[SignalMethod]
#[Webhook]
public function confirm($paymentId)
{
$this->confirmed = true;
$this->paymentId = $paymentId;
}
}
Registering Routes
In your routes/web.php or service provider:
use Workflow\Webhooks;
Webhooks::routes();
This automatically discovers all workflows with the #[Webhook] attribute and registers routes.
Custom Configuration
// Custom namespace and path
Webhooks::routes(
customNamespace: 'App\\Workflows',
customAppPath: app_path('Workflows')
);
Generated Routes
The webhook system generates two types of routes:
1. Start Workflow Routes
For workflows with #[Webhook] on the class:
POST /webhooks/start/{workflow-slug}
Example:
curl -X POST https://yourapp.com/webhooks/start/payment-workflow \
-H "Content-Type: application/json" \
-d '{"amount": 100, "customerId": "cus_123"}'
2. Signal Routes
For methods with #[SignalMethod] and #[Webhook]:
POST /webhooks/signal/{workflow-slug}/{workflowId}/{signal-name}
Example:
curl -X POST https://yourapp.com/webhooks/signal/payment-workflow/wf_abc123/confirm \
-H "Content-Type: application/json" \
-d '{"paymentId": "pi_123"}'
Route Configuration
Customize the base path in config/workflows.php:
return [
'webhooks_route' => 'webhooks', // Default: /webhooks/*
// Or use a custom path
'webhooks_route' => 'api/workflow-events',
];
Parameter Mapping
Webhook requests automatically map JSON payload to method parameters:
#[Webhook]
class OrderWorkflow extends Workflow
{
// Maps JSON: {"orderId": "123", "priority": "high"}
public function execute($orderId, $priority = 'normal')
{
yield $this->processOrder($orderId, $priority);
}
}
From the source code at src/Webhooks.php:137-153, the system:
- Inspects method parameters using reflection
- Matches parameter names to JSON keys
- Uses default values when keys are missing
- Maintains parameter order
Authentication
Secure your webhooks with built-in authentication methods.
None (Development Only)
// config/workflows.php
return [
'webhook_auth' => [
'method' => 'none', // ⚠️ Not recommended for production
],
];
Token-Based Authentication
// config/workflows.php
return [
'webhook_auth' => [
'method' => 'token',
'token' => [
'header' => 'X-Workflow-Token',
'token' => env('WORKFLOW_WEBHOOK_TOKEN'),
],
],
];
Request:
curl -X POST https://yourapp.com/webhooks/start/payment-workflow \
-H "X-Workflow-Token: your-secret-token" \
-H "Content-Type: application/json" \
-d '{"amount": 100}'
HMAC Signature Authentication
For webhook providers like Stripe, GitHub, etc.:
// config/workflows.php
return [
'webhook_auth' => [
'method' => 'signature',
'signature' => [
'header' => 'X-Signature',
'secret' => env('WEBHOOK_SIGNING_SECRET'),
],
],
];
How it works (from src/Auth/SignatureAuthenticator.php:13-16):
- Compute HMAC-SHA256 of request body using secret
- Compare with signature header using timing-safe comparison
- Reject request if signatures don’t match
Request:
# Server computes: hash_hmac('sha256', $request_body, $secret)
curl -X POST https://yourapp.com/webhooks/start/payment-workflow \
-H "X-Signature: abc123..." \
-H "Content-Type: application/json" \
-d '{"amount": 100}'
Custom Authentication
Implement your own authenticator:
use Workflow\Auth\WebhookAuthenticator;
use Illuminate\Http\Request;
class ApiKeyAuthenticator implements WebhookAuthenticator
{
public function validate(Request $request): Request
{
$apiKey = $request->header('X-API-Key');
if (!$this->isValidApiKey($apiKey)) {
abort(401, 'Invalid API key');
}
return $request;
}
private function isValidApiKey($key): bool
{
return ApiKey::where('key', $key)
->where('active', true)
->exists();
}
}
Configuration:
// config/workflows.php
return [
'webhook_auth' => [
'method' => 'custom',
'custom' => [
'class' => App\Auth\ApiKeyAuthenticator::class,
],
],
];
Security Considerations
Never use method: 'none' in production. Always authenticate webhook requests to prevent unauthorized workflow execution.
Best Practices
Always use HTTPS in production to prevent man-in-the-middle attacks:// In middleware or service provider
if (!$request->secure() && app()->environment('production')) {
abort(403, 'HTTPS required');
}
# Generate new secret
php artisan tinker
>>> Str::random(64)
# Update .env
WORKFLOW_WEBHOOK_TOKEN=new-secret-here
Apply rate limiting to webhook endpoints:Route::middleware(['throttle:60,1'])->group(function () {
Webhooks::routes();
});
Always validate webhook payloads:public function execute($orderId)
{
$validated = validator(
['orderId' => $orderId],
['orderId' => 'required|string|exists:orders,id']
)->validate();
yield $this->processOrder($validated['orderId']);
}
Restrict webhooks to known IP addresses:// In custom authenticator
$allowedIps = ['192.168.1.1', '10.0.0.1'];
if (!in_array($request->ip(), $allowedIps)) {
abort(403, 'IP not allowed');
}
Integration Examples
Stripe Webhooks
use Workflow\Workflow;
use Workflow\Webhook;
use Workflow\SignalMethod;
#[Webhook]
class StripePaymentWorkflow extends Workflow
{
public function execute($amount, $customerId)
{
// Create payment intent
$intent = yield $this->createPaymentIntent($amount, $customerId);
// Wait for webhook confirmation
yield $this->awaitSignal('payment-succeeded');
// Fulfill order
yield $this->fulfillOrder();
}
#[SignalMethod]
#[Webhook]
public function paymentSucceeded($paymentIntentId)
{
$this->status = 'paid';
$this->paymentIntentId = $paymentIntentId;
}
}
Stripe Configuration:
Endpoint URL: https://yourapp.com/webhooks/signal/stripe-payment-workflow/{workflowId}/payment-succeeded
Events: payment_intent.succeeded
GitHub Webhooks
#[Webhook]
class DeploymentWorkflow extends Workflow
{
public function execute($repository, $branch)
{
yield $this->awaitSignal('push');
yield $this->runTests();
yield $this->deploy();
}
#[SignalMethod]
#[Webhook]
public function push($commit, $author)
{
$this->latestCommit = $commit;
$this->author = $author;
}
}
Custom API Integration
#[Webhook]
class OrderWorkflow extends Workflow
{
public function execute($orderId)
{
yield $this->createOrder($orderId);
yield $this->awaitSignal('shipping-updated');
yield $this->notifyCustomer();
}
#[SignalMethod]
#[Webhook]
public function shippingUpdated($trackingNumber, $carrier)
{
$this->trackingNumber = $trackingNumber;
$this->carrier = $carrier;
}
}
Getting Webhook URLs
In activities, get webhook URLs programmatically:
use Workflow\Activity;
class SendEmailActivity extends Activity
{
public function execute($workflowId, $email)
{
$confirmUrl = $this->webhookUrl('confirm');
$cancelUrl = $this->webhookUrl('cancel');
Mail::to($email)->send(new ConfirmationEmail([
'confirm_url' => $confirmUrl,
'cancel_url' => $cancelUrl,
]));
}
}
From src/Activity.php:92-104, the webhookUrl() method generates the correct URL based on:
- Workflow class name (converted to kebab-case)
- Workflow ID (for signal methods)
- Signal method name
All webhook endpoints return JSON:
Start Workflow:
{
"message": "Workflow started"
}
Send Signal:
{
"message": "Signal sent"
}
Error Responses
401 Unauthorized:
{
"message": "Unauthorized"
}
404 Not Found:
{
"message": "Not Found"
}
500 Server Error:
{
"message": "Server Error",
"exception": "..."
}
Testing Webhooks
use Illuminate\Foundation\Testing\RefreshDatabase;
class WebhookTest extends TestCase
{
use RefreshDatabase;
public function test_start_workflow_webhook()
{
$response = $this->postJson('/webhooks/start/payment-workflow', [
'amount' => 100,
'customerId' => 'cus_123',
], [
'X-Workflow-Token' => config('workflows.webhook_auth.token.token'),
]);
$response->assertStatus(200)
->assertJson(['message' => 'Workflow started']);
$this->assertDatabaseHas('stored_workflows', [
'class' => PaymentWorkflow::class,
]);
}
public function test_signal_webhook()
{
$workflow = WorkflowStub::make(PaymentWorkflow::class)
->start(100, 'cus_123');
$response = $this->postJson(
"/webhooks/signal/payment-workflow/{$workflow->id()}/confirm",
['paymentId' => 'pi_123'],
['X-Workflow-Token' => config('workflows.webhook_auth.token.token')]
);
$response->assertStatus(200)
->assertJson(['message' => 'Signal sent']);
}
public function test_unauthorized_request()
{
$response = $this->postJson('/webhooks/start/payment-workflow', [
'amount' => 100,
]);
$response->assertStatus(401);
}
}
Route Discovery
The webhook system (from src/Webhooks.php:26-116) automatically:
- Scans the workflows directory
- Finds classes extending
Workflow with #[Webhook] attribute
- Registers POST routes for the
execute method
- Finds methods with
#[SignalMethod] and #[Webhook] attributes
- Registers POST routes for each signal method
- Converts class/method names to kebab-case for URLs