Skip to content

Commit 4357b17

Browse files
authored
Feature: Process Twilio Programmable Messaging webhooks (#2)
* 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
1 parent b3152cb commit 4357b17

4 files changed

+100
-10
lines changed

README.md

+32-5
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
![https://github.com/binary-cats/laravel-twilio-webhooks/actions](https://github.com/binary-cats/laravel-twilio-webhooks/workflows/Laravel/badge.svg)
44

55
[Twilio](https://twilio.com) can notify your application of various engagement events using webhooks. This package can help you handle those webhooks.
6-
Out of the box it will verify the Twilio signature of all incoming requests. All valid calls will be logged to the database.
6+
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.
77
You can easily define jobs or events that should be dispatched when specific events hit your app.
88

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

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

126+
### Programmable Messaging (Outbound) Webhook Event Types
127+
128+
At the time of this writing, the following event types are used by Programmable Messaging Webhooks:
129+
130+
- `queued`
131+
- `canceled`
132+
- `sent`
133+
- `failed`
134+
- `delivered`
135+
- `undelivered`
136+
- `read`
137+
138+
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).
139+
140+
126141
### Handling webhook requests using jobs
127142
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:
128143

@@ -158,7 +173,7 @@ class HandleInitiated implements ShouldQueue
158173
}
159174
```
160175

161-
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.
176+
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.
162177

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

217-
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.
232+
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.
218233

219234
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).
220235

221236
## Advanced usage
222237

238+
### Adding Metadata to the Webhook Call
239+
240+
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.
241+
242+
To add metadata, simply append your custom key-value pairs as URL parameters to the `statusCallback` URL in your Twilio API request. For example:
243+
244+
https://yourdomain.com/webhooks/twilio.com?order_id=12345&user_id=67890
245+
246+
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.
247+
248+
**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.
249+
223250
### Retry handling a webhook
224251

225252
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:
@@ -267,7 +294,7 @@ Route::twilioWebhooks('webhooks/twilio.com/{configKey}');
267294
Alternatively, if you are manually defining the route, you can add `configKey` like so:
268295

269296
```php
270-
Route::post('webhooks/twilio.com/{configKey}', 'BinaryCats\MailgunWebhooks\MailgunWebhooksController');
297+
Route::post('webhooks/twilio.com/{configKey}', 'BinaryCats\TwilioWebhooks\TwilioWebhooksController');
271298
```
272299

273300
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`.
@@ -281,7 +308,7 @@ Example config might look like:
281308
'signing_token_my-alternative-secret' => 'whsec_123',
282309
```
283310

284-
### About Mailgun
311+
### About Twilio
285312

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

src/ProcessTwilioWebhookJob.php

+20-4
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,18 @@
1010
class ProcessTwilioWebhookJob extends ProcessWebhookJob
1111
{
1212
/**
13-
* Name of the payload key to contain the type of event.
13+
* Name of the payload keys to contain the type of event.
14+
*
15+
* @var array
16+
*/
17+
protected $keys = ['CallStatus', 'SmsStatus'];
18+
19+
/**
20+
* The current key being used.
1421
*
1522
* @var string
1623
*/
17-
protected $key = 'CallStatus';
24+
protected $key = 'CallStatus'; // Default to 'CallStatus'
1825

1926
/**
2027
* Handle the process.
@@ -23,7 +30,14 @@ class ProcessTwilioWebhookJob extends ProcessWebhookJob
2330
*/
2431
public function handle()
2532
{
26-
$type = Arr::get($this->webhookCall, "payload.{$this->key}");
33+
$type = null;
34+
foreach ($this->keys as $key) {
35+
$type = Arr::get($this->webhookCall, "payload.{$key}");
36+
if ($type) {
37+
$this->key = $key;
38+
break;
39+
}
40+
}
2741

2842
if (! $type) {
2943
throw WebhookFailed::missingType($this->webhookCall);
@@ -58,7 +72,9 @@ public function getKey(): string
5872
*/
5973
public function setKey(string $key)
6074
{
61-
$this->key = $key;
75+
if (in_array($key, $this->keys)) {
76+
$this->key = $key;
77+
}
6278

6379
return $this;
6480
}

src/WebhookSignature.php

+12-1
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,17 @@ public static function make(Request $request, string $signature, string $secret)
6060
*/
6161
public function verify(): bool
6262
{
63-
return $this->validator->validate($this->signature, $this->request->fullUrl(), $this->request->all());
63+
// Extract the URL parameters from the URL
64+
$fullUrl = $this->request->fullUrl();
65+
$queryParams = [];
66+
parse_str(parse_url($fullUrl, PHP_URL_QUERY), $queryParams);
67+
68+
// Remove each key found in the URL parameters from the request data
69+
$requestData = $this->request->all();
70+
foreach ($queryParams as $key => $value) {
71+
unset($requestData[$key]);
72+
}
73+
74+
return $this->validator->validate($this->signature, $fullUrl, $requestData);
6475
}
6576
}

tests/TwilioWebhookCallTest.php

+36
Original file line numberDiff line numberDiff line change
@@ -102,4 +102,40 @@ public function it_will_change_the_key_for_the_job()
102102
$this->assertEquals($job, $job->setKey('SmsStatus'));
103103
$this->assertEquals('SmsStatus', $job->getKey());
104104
}
105+
106+
/** @test */
107+
public function it_handles_call_status_key_in_webhook_payload()
108+
{
109+
$webhookCall = WebhookCall::create([
110+
'name' => 'twilio',
111+
'payload' => [
112+
'CallStatus' => 'completed',
113+
'key' => 'value',
114+
],
115+
'url' => '/webhooks/twilio.com',
116+
]);
117+
118+
$job = new ProcessTwilioWebhookJob($webhookCall);
119+
$job->handle();
120+
121+
$this->assertEquals('CallStatus', $job->getKey());
122+
}
123+
124+
/** @test */
125+
public function it_handles_sms_status_key_in_webhook_payload()
126+
{
127+
$webhookCall = WebhookCall::create([
128+
'name' => 'twilio',
129+
'payload' => [
130+
'SmsStatus' => 'delivered',
131+
'key' => 'value',
132+
],
133+
'url' => '/webhooks/twilio.com',
134+
]);
135+
136+
$job = new ProcessTwilioWebhookJob($webhookCall);
137+
$job->handle();
138+
139+
$this->assertEquals('SmsStatus', $job->getKey());
140+
}
105141
}

0 commit comments

Comments
 (0)