Skip to content

Commit

Permalink
Feature: Process Twilio Programmable Messaging webhooks (#2)
Browse files Browse the repository at this point in the history
* replace mentions of Mailgun with Twilio

* convert  to an array and check for both CallStatus and SmsStatus to allow for Programmable Messaging webhooks

* add Programmable Messaging (Outbound) Webhook Event Types section to readme

* fix webhook validation by removing query parameters in callback url from request data

* update readme to include information about passing metadata in callback url

* update readme to include note about alphabetizing url parameters in statusCallback URLs to avoid verification failures

* readme typo

* restore $key and adjust setter

* create two new tests to confirm CallStatus or SmsStatus
  • Loading branch information
adamsrog authored Sep 23, 2024
1 parent b3152cb commit 4357b17
Show file tree
Hide file tree
Showing 4 changed files with 100 additions and 10 deletions.
37 changes: 32 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
![https://github.com/binary-cats/laravel-twilio-webhooks/actions](https://github.com/binary-cats/laravel-twilio-webhooks/workflows/Laravel/badge.svg)

[Twilio](https://twilio.com) can notify your application of various engagement events using webhooks. This package can help you handle those webhooks.
Out of the box it will verify the Twilio signature of all incoming requests. All valid calls will be logged to the database.
Out of the box it will verify the Twilio signature of all incoming requests. All valid calls and messages will be logged to the database.
You can easily define jobs or events that should be dispatched when specific events hit your app.

This package will not handle what should be done _after_ the webhook request has been validated and the right job or event is called.
Expand Down Expand Up @@ -123,6 +123,21 @@ There are two ways this package enables you to handle webhook requests: you can

**Please make sure your configured keys are lowercase, as the package will automatically ensure they are**

### Programmable Messaging (Outbound) Webhook Event Types

At the time of this writing, the following event types are used by Programmable Messaging Webhooks:

- `queued`
- `canceled`
- `sent`
- `failed`
- `delivered`
- `undelivered`
- `read`

For the most up-to-date information and additional details, please refer to the official Twilio documentation: [Twilio Programmable Messaging: Outbound Message Status in Status Callbacks](https://www.twilio.com/docs/messaging/guides/outbound-message-status-in-status-callbacks#message-status-changes-triggering-status-callback-requests).


### Handling webhook requests using jobs
If you want to do something when a specific event type comes in you can define a job that does the work. Here's an example of such a job:

Expand Down Expand Up @@ -158,7 +173,7 @@ class HandleInitiated implements ShouldQueue
}
```

Spatie highly recommends that you make this job queueable, because this will minimize the response time of the webhook requests. This allows you to handle more Mailgun webhook requests and avoid timeouts.
Spatie highly recommends that you make this job queueable, because this will minimize the response time of the webhook requests. This allows you to handle more Twilio webhook requests and avoid timeouts.

After having created your job you must register it at the `jobs` array in the `twilio-webhooks.php` config file.\
The key should be the name of twilio event type.\
Expand Down Expand Up @@ -214,12 +229,24 @@ class InitiatedCall implements ShouldQueue
}
```

Spatie highly recommends that you make the event listener queueable, as this will minimize the response time of the webhook requests. This allows you to handle more Mailgun webhook requests and avoid timeouts.
Spatie highly recommends that you make the event listener queueable, as this will minimize the response time of the webhook requests. This allows you to handle more Twilio webhook requests and avoid timeouts.

The above example is only one way to handle events in Laravel. To learn the other options, read [the Laravel documentation on handling events](https://laravel.com/docs/9.x/events).

## Advanced usage

### Adding Metadata to the Webhook Call

You can pass additional metadata with your Twilio webhooks by [adding URL parameters to the `statusCallback` URL](https://www.twilio.com/docs/messaging/guides/outbound-message-logging#sending-additional-message-identifiers). This metadata will be accesible in the payload (i.e. `$this->webhookCall->payload`), allowing you to pass additional context or information that you might need when processing the webhook.

To add metadata, simply append your custom key-value pairs as URL parameters to the `statusCallback` URL in your Twilio API request. For example:

https://yourdomain.com/webhooks/twilio.com?order_id=12345&user_id=67890

In this example, order_id=12345 and user_id=67890 are custom parameters that will be passed back with the webhook payload. Twilio will include these parameters in the webhook request, allowing you to access this information directly in your webhook processing logic.

**Note:** When building your `statusCallback` URL, ensure that the query parameter keys are alphabetized. This is necessary to prevent webhook verification failures because the `Request` facade's [`fullUrl()` function](https://laravel.com/api/9.x/Illuminate/Support/Facades/Request.html#method_fullUrl) (i.e., `$request->fullUrl()`) automatically returns the query parameters in alphabetical order.

### Retry handling a webhook

All incoming webhook requests are written to the database. This is incredibly valuable when something goes wrong while handling a webhook call. You can easily retry processing the webhook call, after you've investigated and fixed the cause of failure, like this:
Expand Down Expand Up @@ -267,7 +294,7 @@ Route::twilioWebhooks('webhooks/twilio.com/{configKey}');
Alternatively, if you are manually defining the route, you can add `configKey` like so:

```php
Route::post('webhooks/twilio.com/{configKey}', 'BinaryCats\MailgunWebhooks\MailgunWebhooksController');
Route::post('webhooks/twilio.com/{configKey}', 'BinaryCats\TwilioWebhooks\TwilioWebhooksController');
```

If this route parameter is present verify middleware will look for the secret using a different config key, by appending the given the parameter value to the default config key. E.g. If Twilio posts to `webhooks/twilio.com/my-named-secret` you'd add a new config named `signing_token_my-named-secret`.
Expand All @@ -281,7 +308,7 @@ Example config might look like:
'signing_token_my-alternative-secret' => 'whsec_123',
```

### About Mailgun
### About Twilio

[Twilio](https://www.twilio.com/) powers personalized interactions and trusted global communications to connect you with customers.

Expand Down
24 changes: 20 additions & 4 deletions src/ProcessTwilioWebhookJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,18 @@
class ProcessTwilioWebhookJob extends ProcessWebhookJob
{
/**
* Name of the payload key to contain the type of event.
* Name of the payload keys to contain the type of event.
*
* @var array
*/
protected $keys = ['CallStatus', 'SmsStatus'];

/**
* The current key being used.
*
* @var string
*/
protected $key = 'CallStatus';
protected $key = 'CallStatus'; // Default to 'CallStatus'

/**
* Handle the process.
Expand All @@ -23,7 +30,14 @@ class ProcessTwilioWebhookJob extends ProcessWebhookJob
*/
public function handle()
{
$type = Arr::get($this->webhookCall, "payload.{$this->key}");
$type = null;
foreach ($this->keys as $key) {
$type = Arr::get($this->webhookCall, "payload.{$key}");
if ($type) {
$this->key = $key;
break;
}
}

if (! $type) {
throw WebhookFailed::missingType($this->webhookCall);
Expand Down Expand Up @@ -58,7 +72,9 @@ public function getKey(): string
*/
public function setKey(string $key)
{
$this->key = $key;
if (in_array($key, $this->keys)) {
$this->key = $key;
}

return $this;
}
Expand Down
13 changes: 12 additions & 1 deletion src/WebhookSignature.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,17 @@ public static function make(Request $request, string $signature, string $secret)
*/
public function verify(): bool
{
return $this->validator->validate($this->signature, $this->request->fullUrl(), $this->request->all());
// Extract the URL parameters from the URL
$fullUrl = $this->request->fullUrl();
$queryParams = [];
parse_str(parse_url($fullUrl, PHP_URL_QUERY), $queryParams);

// Remove each key found in the URL parameters from the request data
$requestData = $this->request->all();
foreach ($queryParams as $key => $value) {
unset($requestData[$key]);
}

return $this->validator->validate($this->signature, $fullUrl, $requestData);
}
}
36 changes: 36 additions & 0 deletions tests/TwilioWebhookCallTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,40 @@ public function it_will_change_the_key_for_the_job()
$this->assertEquals($job, $job->setKey('SmsStatus'));
$this->assertEquals('SmsStatus', $job->getKey());
}

/** @test */
public function it_handles_call_status_key_in_webhook_payload()
{
$webhookCall = WebhookCall::create([
'name' => 'twilio',
'payload' => [
'CallStatus' => 'completed',
'key' => 'value',
],
'url' => '/webhooks/twilio.com',
]);

$job = new ProcessTwilioWebhookJob($webhookCall);
$job->handle();

$this->assertEquals('CallStatus', $job->getKey());
}

/** @test */
public function it_handles_sms_status_key_in_webhook_payload()
{
$webhookCall = WebhookCall::create([
'name' => 'twilio',
'payload' => [
'SmsStatus' => 'delivered',
'key' => 'value',
],
'url' => '/webhooks/twilio.com',
]);

$job = new ProcessTwilioWebhookJob($webhookCall);
$job->handle();

$this->assertEquals('SmsStatus', $job->getKey());
}
}

0 comments on commit 4357b17

Please sign in to comment.