Webhook
A webhook is a mechanism for sending event notifications between systems, typically delivered via HTTP POST requests.
The Webhook component provides two primary capabilities:
- Consuming: receive and process webhook calls from remote systems;
- Sending: dispatch webhook callbacks to registered endpoints when events occur.
Installation
1
$ composer require symfony/webhook
Consuming Webhooks
The Webhook component, combined with RemoteEvent, enables you to receive and process webhooks through three phases:
- Receiving the webhook via a dedicated endpoint
- Verifying the webhook and converting it to a RemoteEvent object
- Consuming the event in your application logic
Screencast
Like video tutorials? Check out the Webhook Component for Email Events screencast.
A Centralized Webhook Endpoint
The WebhookController provides a single entry point for receiving all incoming webhooks, regardless of their source (third-party services, custom APIs, etc.).
By default, any URL prefixed with /webhook routes to this controller. You
can customize this prefix in your routing configuration:
1 2 3 4
# config/routes/webhook.yaml
webhook:
resource: '@FrameworkBundle/Resources/config/routing/webhook.xml'
prefix: /webhook # customize as needed
Next, configure the parser services that will handle incoming webhooks. The controller uses a routing mechanism to map incoming requests to the appropriate parser:
1 2 3 4 5 6 7
# config/packages/webhook.yaml
framework:
webhook:
routing:
acme_webhook: # routing name, maps to /webhook/acme_webhook
service: App\Webhook\AcmeWebhookRequestParser
secret: '%env(WEBHOOK_SECRET)%' # optional
The routing name becomes part of the webhook URL (e.g.,
https://example.com/webhook/acme_webhook). Each routing name must be
unique as it connects the webhook source to your consumer code.
All parsers are automatically injected into the WebhookController.
Parsing Webhook Requests
Once a webhook request arrives at your endpoint, it must be parsed and validated before your application can process it. Parsing involves verifying the request's authenticity (typically via signature validation), extracting the payload, and converting it into a RemoteEvent object.
Symfony provides two approaches to handle parsing:
- Built-in parser: use the standard RequestParser for webhooks from other Symfony applications;
- Custom parser: create your own parser for webhooks from third-party services or custom APIs.
Using the Built-in Parser
For webhooks originating from other Symfony applications, you can use the built-in RequestParser instead of creating a custom parser. This parser handles the standard Symfony webhook request format:
1 2 3 4 5 6 7
# config/packages/framework.yaml
framework:
webhook:
routing:
acme_webhook:
service: Symfony\Component\Webhook\Client\RequestParser
secret: '%env(WEBHOOK_SECRET)%'
The built-in parser automatically handles request validation and signature verification, allowing you to focus on consuming the RemoteEvent in your application logic.
Creating a Custom Parser
For webhooks from custom APIs, implement a parser using RequestParserInterface or extend AbstractRequestParser.
The easiest way is using the maker command:
1
$ php bin/console make:webhook
Tip
Starting in MakerBundle v1.58.0, the make:webhook command generates
both the parser and consumer classes and updates your configuration automatically.
When extending AbstractRequestParser, you need to implement two methods:
- getRequestMatcher() to validate the incoming request format;
- doParse() to verify the webhook and parse it into a RemoteEvent.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
// src/Webhook/AcmeWebhookRequestParser.php
namespace App\Webhook;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestMatcher\ChainRequestMatcher;
use Symfony\Component\HttpFoundation\RequestMatcher\IsJsonRequestMatcher;
use Symfony\Component\HttpFoundation\RequestMatcher\MethodRequestMatcher;
use Symfony\Component\HttpFoundation\RequestMatcher\RequestMatcherInterface;
use Symfony\Component\RemoteEvent\RemoteEvent;
use Symfony\Component\Webhook\Client\AbstractRequestParser;
final class AcmeWebhookRequestParser extends AbstractRequestParser
{
protected function getRequestMatcher(): RequestMatcherInterface
{
return new ChainRequestMatcher([
new IsJsonRequestMatcher(),
new MethodRequestMatcher('POST'),
]);
}
protected function doParse(
Request $request,
#[\SensitiveParameter] string $secret
): ?RemoteEvent {
$payload = $request->toArray();
return new RemoteEvent(
$payload['event_type'],
$payload['event_id'],
$payload,
);
}
}
The doParse() method receives the request and the secret. You should:
- Validate the request signature (typically HMAC-SHA256)
- Parse and validate the payload
- Throw a RejectWebhookException for invalid requests
- Return a RemoteEvent on success
Testing Your Parser
Test your custom parser by extending AbstractRequestParserTest.
This base class runs testParse()
with data from getPayloads(),
which loads files from Fixtures/*.json and pairs each with a .php expectation file:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
// tests/Webhook/AcmeWebhookRequestParserTest.php
namespace App\Tests\Webhook;
use App\Webhook\AcmeWebhookRequestParser;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Webhook\Client\Tests\AbstractRequestParserTest;
class AcmeWebhookRequestParserTest extends AbstractRequestParserTest
{
protected function createRequestParser(): AcmeWebhookRequestParser
{
return new AcmeWebhookRequestParser();
}
// default createRequest() builds a POST request with Content-Type: application/json
// override it to add provider-specific headers (e.g., webhook signatures) or change the method
protected function createRequest(string $payload): Request
{
return Request::create('/', 'POST', [], [], [], // the routing is not actually tested
[
'CONTENT_TYPE' => 'application/json', // add headers as needed
],
$payload
);
}
}
Create the fixture files that the base test expects (e.g. in
tests/Webhook/Fixtures/resource.created.json):
1 2 3 4 5
{
"event_type": "resource.created",
"event_id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com"
}
and:
1 2 3 4 5 6 7 8 9 10 11 12
// tests/Webhook/Fixtures/resource.created.php
use Symfony\Component\RemoteEvent\RemoteEvent;
return new RemoteEvent(
name: 'resource.created',
id: '550e8400-e29b-41d4-a716-446655440000',
payload: [
'event_type' => 'resource.created',
'event_id' => '550e8400-e29b-41d4-a716-446655440000',
'email' => 'user@example.com',
]
);
Your test must implement createRequestParser() to return an instance of your RequestParserInterface implementation.
You can also override the following methods in your test:
- getSecret() if your parser validates signatures
- getFixtureExtension()
if your fixtures are not
.json(e.g.,.txtfor form-encoded payloads)
Handling Complex Payload Transformations
For complex webhook payloads, use PayloadConverterInterface to encapsulate transformation logic:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
// src/RemoteEvent/AcmeWebhookPayloadConverter.php
namespace App\RemoteEvent;
use Symfony\Component\RemoteEvent\PayloadConverterInterface;
use Symfony\Component\RemoteEvent\RemoteEvent;
final class AcmeWebhookPayloadConverter implements PayloadConverterInterface
{
public function convert(array $payload): RemoteEvent
{
// map external event names to your domain events
$eventName = match ($payload['event_type']) {
'resource.created' => 'acme.resource_created',
'resource.updated' => 'acme.resource_updated',
'resource.deleted' => 'acme.resource_deleted',
default => 'acme.unknown_event',
};
return new RemoteEvent($eventName, $payload['event_id'], $payload);
}
}
Then inject it into your parser:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
// src/Webhook/AcmeWebhookRequestParser.php
namespace App\Webhook;
use App\RemoteEvent\AcmeWebhookPayloadConverter;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\RemoteEvent\PayloadConverterInterface;
use Symfony\Component\RemoteEvent\RemoteEvent;
use Symfony\Component\Webhook\Client\AbstractRequestParser;
use Symfony\Component\Webhook\Exception\RejectWebhookException;
use Symfony\DependencyInjection\Attribute\Autowire;
final class AcmeWebhookRequestParser extends AbstractRequestParser
{
public function __construct(
#[Autowire(service: AcmeWebhookPayloadConverter::class)]
private readonly PayloadConverterInterface $converter,
) {
}
// ... getRequestMatcher() as before
protected function doParse(
Request $request,
#[\SensitiveParameter] string $secret,
): ?RemoteEvent {
try {
return $this->converter->convert($request->toArray());
} catch (ParseException|\JsonException $e) {
throw new RejectWebhookException(406, $e->getMessage(), $e);
}
}
}
Tip
For inspiration, look at the built-in MailgunPayloadConverter.
Consuming the RemoteEvent
Whether processed synchronously or asynchronously (via Messenger), you need a consumer implementing ConsumerInterface.
The make:webhook command generates one automatically. Otherwise, create
it manually using the
AsRemoteEventConsumer
attribute:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
// src/RemoteEvent/AcmeWebhookConsumer.php
namespace App\RemoteEvent;
use Symfony\Component\RemoteEvent\Attribute\AsRemoteEventConsumer;
use Symfony\Component\RemoteEvent\Consumer\ConsumerInterface;
use Symfony\Component\RemoteEvent\RemoteEvent;
#[AsRemoteEventConsumer('acme_webhook')] // must match routing name
final class AcmeWebhookConsumer implements ConsumerInterface
{
public function consume(RemoteEvent $event): void
{
// handle the event based on your business logic
}
}
The name passed to the AsRemoteEventConsumer attribute must match the
routing name defined in your webhook configuration.
Asynchronous Consuming
By default, webhook consumers are invoked synchronously when the RemoteEvent is dispatched. To process webhooks asynchronously, configure Messenger routing for ConsumeRemoteEventMessage:
1 2 3 4 5
# config/packages/messenger.yaml
framework:
messenger:
routing:
'Symfony\Component\RemoteEvent\Messenger\ConsumeRemoteEventMessage': async
With this configuration, consumers are invoked asynchronously via the message bus. Without it, consumers are processed synchronously during the webhook request.
Built-in Integrations
Symfony provides pre-built parsers for common services, so you don't need to create custom parsers for them. You still need to create your own consumer to handle the RemoteEvent according to your business logic.
Mailer Webhooks
Receive delivery and engagement notifications from third-party mailers:
| Mailer Service | Parser service name |
|---|---|
| AhaSend | mailer.webhook.request_parser.ahasend |
| Brevo | mailer.webhook.request_parser.brevo |
| Mandrill | mailer.webhook.request_parser.mailchimp |
| MailerSend | mailer.webhook.request_parser.mailersend |
| Mailgun | mailer.webhook.request_parser.mailgun |
| Mailjet | mailer.webhook.request_parser.mailjet |
| Mailomat | mailer.webhook.request_parser.mailomat |
| Mailtrap | mailer.webhook.request_parser.mailtrap |
| Postmark | mailer.webhook.request_parser.postmark |
| Resend | mailer.webhook.request_parser.resend |
| Sendgrid | mailer.webhook.request_parser.sendgrid |
| Sweego | mailer.webhook.request_parser.sweego |
7.1
The support for Resend and MailerSend were introduced in Symfony 7.1.
7.2
The Mandrill, Mailomat, Mailtrap, and Sweego integrations were introduced in
Symfony 7.2.
7.3
The AhaSend integration was introduced in Symfony 7.3.
Note
Install the third-party mailer provider you want to use as described in the documentation of the Mailer component. Mailgun is used as the provider in this document as an example.
Configure the routing:
1 2 3 4 5 6 7
# config/packages/framework.yaml
framework:
webhook:
routing:
mailer_mailgun:
service: 'mailer.webhook.request_parser.mailgun'
secret: '%env(MAILER_MAILGUN_SECRET)%'
The routing name becomes part of your webhook URL (e.g.,
https://example.com/webhook/mailer_mailgun). Configure this URL at your
mailer provider and store the webhook secret in your environment (via the
secrets management system or in a .env file).
Then create a consumer to handle delivery and engagement events:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
// src/RemoteEvent/MailerWebhookConsumer.php
namespace App\RemoteEvent;
use Symfony\Component\RemoteEvent\Attribute\AsRemoteEventConsumer;
use Symfony\Component\RemoteEvent\Consumer\ConsumerInterface;
use Symfony\Component\RemoteEvent\Event\Mailer\MailerDeliveryEvent;
use Symfony\Component\RemoteEvent\Event\Mailer\MailerEngagementEvent;
use Symfony\Component\RemoteEvent\RemoteEvent;
#[AsRemoteEventConsumer('mailer_mailgun')]
final class MailerWebhookConsumer implements ConsumerInterface
{
public function consume(RemoteEvent $event): void
{
if ($event instanceof MailerDeliveryEvent) {
$this->handleDelivery($event);
} elseif ($event instanceof MailerEngagementEvent) {
$this->handleEngagement($event);
}
}
private function handleDelivery(MailerDeliveryEvent $event): void
{
// Update message status in database, log delivery, etc.
}
private function handleEngagement(MailerEngagementEvent $event): void
{
// Handle opens, clicks, bounces, etc.
}
}
Notifier Webhooks
Receive SMS status notifications from providers:
| SMS service | Parser service name |
|---|---|
| LOX24 | notifier.webhook.request_parser.lox24 |
| Smsbox | notifier.webhook.request_parser.smsbox |
| Sweego | notifier.webhook.request_parser.sweego |
| Twilio | notifier.webhook.request_parser.twilio |
| Vonage | notifier.webhook.request_parser.vonage |
7.4
The support for LOX24 was introduced in Symfony 7.4.
Configure similarly to mailers, then consume SmsEvent:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
// src/RemoteEvent/SmsWebhookConsumer.php
namespace App\RemoteEvent;
use Symfony\Component\RemoteEvent\Attribute\AsRemoteEventConsumer;
use Symfony\Component\RemoteEvent\Consumer\ConsumerInterface;
use Symfony\Component\RemoteEvent\Event\Sms\SmsEvent;
use Symfony\Component\RemoteEvent\RemoteEvent;
#[AsRemoteEventConsumer('notifier_twilio')]
final class SmsWebhookConsumer implements ConsumerInterface
{
public function consume(RemoteEvent $event): void
{
if ($event instanceof SmsEvent) {
$this->handleSms($event);
}
}
private function handleSms(SmsEvent $event): void
{
// Update SMS delivery status in database, etc.
}
}
Sending Webhooks
The Webhook component also enables your application to dispatch webhook callbacks to remote endpoints. This is useful when building APIs that notify subscribers of important events.
To send webhooks, ensure you have installed both the HttpClient and Serializer components:
1
$ composer require symfony/http-client symfony/serializer
Basic Usage
To send a webhook, dispatch a SendWebhookMessage via the Messenger component:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\RemoteEvent\RemoteEvent;
use Symfony\Component\Webhook\Messenger\SendWebhookMessage;
use Symfony\Component\Webhook\Subscriber;
class StockNotifier
{
public function __construct(
private readonly MessageBusInterface $messageBus,
) {
}
public function notifyOutOfStock(int $productId): void
{
$subscriber = new Subscriber(
url: 'https://example.com/webhook/stock',
secret: 'your-shared-secret',
);
$event = new RemoteEvent(
name: 'resource.created',
id: '550e8400-e29b-41d4-a716-446655440000',
payload: [
'resource_id' => 12345,
'email' => 'user@example.com',
'created_at' => time(),
]
);
$this->messageBus->dispatch(
new SendWebhookMessage($subscriber, $event)
);
}
}
The message is processed by SendWebhookHandler, which:
- Constructs the HTTP request body (JSON-encoded payload)
- Adds standard headers:
Webhook-Event(event name),Webhook-Id(event ID),Webhook-Signature(HMAC-SHA256 signature of the concatenated event name, ID, and body), andContent-Type: application/json - Signs the request using the subscriber's secret
- Sends the HTTP request using the Symfony HttpClient component
Resulting HTTP Request
When the webhook is sent, it generates an HTTP POST request with the following format:
1 2 3 4 5 6 7 8 9 10 11 12
POST /webhook/symfony HTTP/1.1
Host: example.com
Content-Type: application/json
Webhook-Event: resource.created
Webhook-Id: 550e8400-e29b-41d4-a716-446655440000
Webhook-Signature: sha256=9f86d081884c7d6d9ffd60bb51d3263112c4b2486f80fa12ab5807265dc789d6
{
"resource_id": 12345,
"email": "user@example.com",
"created_at": 1234567890
}
By default, the signature uses HMAC-SHA256 of the concatenated event name, event ID, and JSON body. Receiving endpoints should verify this signature using the shared secret to ensure webhook authenticity.
Custom Sending Logic
For advanced use cases, you can implement custom sending logic using TransportInterface to control header generation, signing, and HTTP transport.