用 PHP 和 Laravel 收发 WhatsApp 消息
接收消息靠一个 Laravel 路由:它应答 Meta 的验证 GET 请求并接受 POST 请求,参数要读作 hub_mode、hub_verify_token 和 hub_challenge,因为 PHP 会把点号转成下划线。发送则是向 Cloud API 的 messages 端点发一次 Http::withToken() POST 请求,带上该号码的 phone number id 和访问令牌。无需 SDK。
PHP 会把传入变量名中的点号转换为下划线,因此 hub.challenge 要读作 hub_challenge。
由 install:api 创建的 Laravel 无状态 API 路由位于 /api 下,不受 web 组 CSRF 保护的约束。
Meta 当前发送消息示例中使用的 Graph API 版本,端点为 POST /{phone-number-id}/messages。
如何在 Laravel 中接收 WhatsApp 消息?
在 routes/api.php 中为同一路径定义一个 GET 和一个 POST。如果该文件不存在,请先运行 php artisan install:api。API 路由是无状态的,且不在 web 组内,所以 Meta 的 POST 请求不会因缺少 CSRF 令牌而被拒绝,而放在 routes/web.php 中的路由就会出现这种情况。
GET 用于应答 Meta 的验证。Meta 发送 hub.mode、hub.verify_token 和 hub.challenge,但 PHP 会把传入变量名中的点号改写成下划线,所以 Laravel 看到的是 hub_mode、hub_verify_token 和 hub_challenge。如果代码读取带点号的名称,得到的只会是 null,验证永远无法通过。请以纯文本返回 challenge,不要使用 response()->json()。
POST 把负载交给队列任务处理,并立即返回 200。请在路径中放入一个足够长的随机密钥,并在两个路由上都校验它:Meta 使用 Tech Provider 的应用密钥对推送签名,因此您这边无法验证签名头。
use App\Jobs\HandleWhatsAppEvent;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::get('/whatsapp/{key}', function (Request $request, string $key) {
abort_unless(hash_equals(config('services.whatsapp.url_key'), $key), 404);
// PHP turns hub.mode into hub_mode: read the underscored names
if ($request->query('hub_mode') === 'subscribe'
&& hash_equals(config('services.whatsapp.verify_token'),
(string) $request->query('hub_verify_token'))) {
return response((string) $request->query('hub_challenge'), 200)
->header('Content-Type', 'text/plain');
}
abort(403);
});
Route::post('/whatsapp/{key}', function (Request $request, string $key) {
abort_unless(hash_equals(config('services.whatsapp.url_key'), $key), 404);
HandleWhatsAppEvent::dispatch($request->json()->all());
return response('', 200);
});如何从 Laravel 发送 WhatsApp 消息?
使用 Laravel 的 HTTP 客户端:向该 phone number id 对应的 messages 端点发一次 POST 请求,并把访问令牌作为 Bearer 令牌。这两个值都来自该连接,可在控制台中查看,也可通过 get_api_credentials 获取,应放在 config/services.php 中并由环境变量提供,而不是写进代码。
请求体的形式取决于时间。在客户最后一条消息后的 24 小时内,可以使用 text 对象,如下所示。超出这个窗口,Meta 只接受指明已批准消息模板及其语言的 template 对象,发送自由文本会失败并返回错误 131047。要排查问题就看响应:失败的调用会返回一个 error 对象,其中的 code 说明了出错原因,请记录下来,不要丢弃。
请求中没有任何内容是 EasyCoexistence 特有的。它就是 Meta 官方文档中的那个调用,因此任何 PHP HTTP 客户端,无论是 Guzzle 还是原生 cURL,用法都一样。
use Illuminate\Support\Facades\Http;
$response = Http::withToken(config('services.whatsapp.token'))
->post('https://graph.facebook.com/v25.0/'
. config('services.whatsapp.phone_number_id') . '/messages', [
'messaging_product' => 'whatsapp',
'to' => '14155550188', // digits only, with country code
'type' => 'text',
'text' => ['body' => 'Thanks, we are on it.'],
]);
if ($response->failed()) {
// error.code 131047: outside the window, send a template instead
logger()->warning('WhatsApp send failed', $response->json('error', []));
}除此之外,生产环境的 Laravel 处理程序还需要什么?
需要真正的队列、去重和窗口检查。
队列之所以重要,是因为 Meta 会重试响应过慢的请求。只有当队列连接是真实的连接且有 worker 在运行时,HandleWhatsAppEvent::dispatch() 才会很快返回。使用 sync 驱动时,任务会在请求内部运行,一次缓慢的模型调用或数据库写入就会变成重复推送。
请按消息 id 去重,也就是每条消息中的 wamid,因为重试是常态,而不是例外。为该列建立唯一索引,或使用以它为键的缓存锁,就足够了。
还要记录每位联系人最后一次发来消息的时间。有了这个时间戳,发送流程就能自行选择 text 还是 template,而不必等客户已经在等待时才从错误 131047 得知。在手机上的 WhatsApp Business 应用中输入的回复,会以 echo 事件的形式到达同一个路由,因此处理程序也能看出何时有人接管了对话。
常见错误
- 读取 hub.challenge 时带了点号。PHP 已将其改名为 hub_challenge,处理程序因此返回空响应体,Meta 永远无法完成验证。
- 把 Webhook 放在 routes/web.php 中。Meta 的 POST 请求没有 CSRF 令牌,会被以 419 拒绝。
- 把任务派发到 sync 队列。任务在返回 200 之前就运行了,处理缓慢会触发重试,消息因此被处理两次。
- 用 response()->json() 返回 challenge。值两侧的引号会导致 Meta 的检查失败。
在 easycoexistence.com 连接号码,把 Webhook 目标设为带有密钥路径的 Laravel 路由,再从控制台读取 phone number id 和访问令牌。每个号码每月 US$ 9 起,随号码增加降至 US$ 1,前 3 天免费。
常见问题
有 PHP SDK 吗?
我们没有提供,也无需 SDK。该 API 由 Meta 提供并由 Meta 撰写文档,Laravel 的 HTTP 客户端或 Guzzle 可以直接调用。
不用 Laravel 也行吗?
可以。在原生 PHP 中读取 $_GET['hub_challenge'],以 text/plain 输出,并用 cURL 发送。下划线规则适用于所有 PHP 框架。
为什么 URL 的路径中需要一个密钥?
因为这是唯一行之有效的身份验证。Meta 的签名使用 Tech Provider 的应用密钥,该密钥绝不会共享,所以 URL 中的密钥才能防止陌生人发送伪造事件。
团队还能继续使用手机吗?
可以。Coexistence 让 WhatsApp Business 应用继续在该号码上使用,从手机发出的消息会以 echo 事件的形式到达您的路由。
继续阅读
准备开始了吗?
几分钟即可设置 WhatsApp Coexistence,而不是几个月。应用会继续在手机上运行。
开始免费试用验证时间