diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 0c6cd1393..743539f78 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -125,4 +125,8 @@ jobs:
TESTS_GITHUB_INSTALLATION_ID: ${{ secrets.TESTS_GITHUB_INSTALLATION_ID }}
TESTS_BITBUCKET_ACCESS_TOKEN: ${{ secrets.TESTS_BITBUCKET_ACCESS_TOKEN }}
TESTS_BITBUCKET_WORKSPACE: ${{ secrets.TESTS_BITBUCKET_WORKSPACE }}
+ TESTS_WHATSAPP_ACCESS_TOKEN: ${{ secrets.TESTS_WHATSAPP_ACCESS_TOKEN }}
+ TESTS_WHATSAPP_PHONE_NUMBER_ID: ${{ secrets.TESTS_WHATSAPP_PHONE_NUMBER_ID }}
+ TESTS_WHATSAPP_BUSINESS_ACCOUNT_ID: ${{ secrets.TESTS_WHATSAPP_BUSINESS_ACCOUNT_ID }}
+ TESTS_WHATSAPP_RECIPIENT: ${{ secrets.TESTS_WHATSAPP_RECIPIENT }}
run: bin/monorepo test ${{ matrix.package }} ${{ matrix.linked && '--linked' || '' }}
diff --git a/.vale/styles/config/vocabularies/Utopia/accept.txt b/.vale/styles/config/vocabularies/Utopia/accept.txt
index 0e78b8319..aca7d977c 100644
--- a/.vale/styles/config/vocabularies/Utopia/accept.txt
+++ b/.vale/styles/config/vocabularies/Utopia/accept.txt
@@ -220,6 +220,7 @@ worktree(?:s)?
webOS
WebView
Wget
+WhatsApp
YAML
Yandex
zstd
diff --git a/packages/messaging/.env.dev b/packages/messaging/.env.dev
index 74dfc2465..3804579d1 100644
--- a/packages/messaging/.env.dev
+++ b/packages/messaging/.env.dev
@@ -36,3 +36,7 @@ FAST2SMS_MESSAGE_ID=
FAST2SMS_TO=
INFORU_API_TOKEN=
INFORU_SENDER_ID=
+TESTS_WHATSAPP_ACCESS_TOKEN=
+TESTS_WHATSAPP_PHONE_NUMBER_ID=
+TESTS_WHATSAPP_BUSINESS_ACCOUNT_ID=
+TESTS_WHATSAPP_RECIPIENT=
diff --git a/packages/messaging/README.md b/packages/messaging/README.md
index ac0deabfe..4d0854cac 100644
--- a/packages/messaging/README.md
+++ b/packages/messaging/README.md
@@ -69,6 +69,46 @@ $messaging = new Telesign('YOUR_USERNAME', 'YOUR_PASSWORD');
$messaging->send($message);
```
+### One-time codes over WhatsApp
+
+The WhatsApp adapter sends codes through a Meta Cloud API authentication template. Meta fixes the template text, so the message content is the code itself: up to 15 letters and digits, no free text. Create the template once per language, then send with the same `SMS` message type.
+
+```php
+upsertTemplate(
+ 'YOUR_BUSINESS_ACCOUNT_ID',
+ ['en_US', 'fr'],
+ expirationMinutes: 10,
+ otpType: OtpType::ONE_TAP,
+ apps: [new App('com.example.app', 'K8a/AINcGX7')],
+);
+
+$messaging->getTemplate('YOUR_BUSINESS_ACCOUNT_ID'); // [['language' => 'en_US', 'status' => 'APPROVED', ...], ...]
+
+$message = new SMS(
+ to: ['+12025550139'],
+ content: '482913'
+);
+$message->setMetadata([
+ MetadataParameter::LANGUAGE->value => 'fr',
+ MetadataParameter::TEMPLATE->value => 'recovery_code',
+ MetadataParameter::CALLBACK_DATA->value => 'token-64f1c2', // echoed back in status webhooks
+]);
+
+$messaging->send($message);
+```
+
## Push
```php
@@ -118,6 +158,7 @@ $messaging->send($message);
- [x] [Seven](https://www.seven.io/)
- [ ] [SmsGlobal](https://www.smsglobal.com/)
- [x] [Inforu](https://www.inforu.co.il/)
+- [x] [WhatsApp](https://developers.facebook.com/docs/whatsapp/cloud-api) (one-time codes through authentication templates)
### Push
- [x] [FCM](https://firebase.google.com/docs/cloud-messaging)
diff --git a/packages/messaging/phpunit.xml b/packages/messaging/phpunit.xml
index 848cf196d..dd543dc65 100644
--- a/packages/messaging/phpunit.xml
+++ b/packages/messaging/phpunit.xml
@@ -12,11 +12,13 @@
tests/Messaging/Adapter/Email/EmailTest.php
tests/Messaging/Adapter/Email/SMTPTest.php
tests/Messaging/Adapter/SMS/SMSTest.php
+ tests/Messaging/Adapter/SMS/WhatsAppTest.php
tests/Messaging/Adapter/Email/EmailTest.php
tests/Messaging/Adapter/Email/SMTPTest.php
tests/Messaging/Adapter/SMS/SMSTest.php
+ tests/Messaging/Adapter/SMS/WhatsAppTest.php
diff --git a/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp.php b/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp.php
new file mode 100644
index 000000000..e9952f1d4
--- /dev/null
+++ b/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp.php
@@ -0,0 +1,446 @@
+assertTemplateName($template);
+
+ parent::__construct(clientFactory: $clientFactory);
+ }
+
+ public function getName(): string
+ {
+ return static::NAME;
+ }
+
+ public function getMaxMessagesPerRequest(): int
+ {
+ return 1;
+ }
+
+ /**
+ * Create or update the authentication template in every given language.
+ *
+ * Meta fixes the body text of authentication templates and approves them
+ * without review, so the response normally reports each language as approved.
+ *
+ * @param string $businessAccountId WhatsApp Business Account ID that owns the template.
+ * @param array $languages Language codes to create the template in; empty creates every supported language.
+ * @param int|null $expirationMinutes Validity stated in the footer, between 1 and 90; null omits the footer.
+ * @param bool $securityRecommendation Whether the body tells the recipient not to share the code.
+ * @param int|null $timeToLive Seconds after which an undelivered message is dropped, between 60 and 600, or -1 for 24 hours.
+ * @param OtpType $otpType How the recipient moves the code into the app; one-tap and zero-tap need at least one app.
+ * @param array $apps Android apps that may receive the code through one-tap or zero-tap autofill, at most five.
+ * @return array Decoded Graph API response.
+ *
+ * @throws \InvalidArgumentException If an option is outside what Meta accepts.
+ * @throws \RuntimeException If the Graph API rejects the request.
+ */
+ public function upsertTemplate(
+ string $businessAccountId,
+ array $languages = [],
+ ?int $expirationMinutes = null,
+ bool $securityRecommendation = true,
+ ?int $timeToLive = null,
+ OtpType $otpType = OtpType::COPY_CODE,
+ array $apps = [],
+ ): array {
+ if ($expirationMinutes !== null && ($expirationMinutes < 1 || $expirationMinutes > self::EXPIRATION_MAX_MINUTES)) {
+ throw new \InvalidArgumentException('WhatsApp code expiration must be between 1 and ' . self::EXPIRATION_MAX_MINUTES . ' minutes.');
+ }
+
+ if ($timeToLive !== null && $timeToLive !== self::TIME_TO_LIVE_DAY && ($timeToLive < self::TIME_TO_LIVE_MIN_SECONDS || $timeToLive > self::TIME_TO_LIVE_MAX_SECONDS)) {
+ throw new \InvalidArgumentException('WhatsApp time to live must be between ' . self::TIME_TO_LIVE_MIN_SECONDS . ' and ' . self::TIME_TO_LIVE_MAX_SECONDS . ' seconds, or ' . self::TIME_TO_LIVE_DAY . ' for 24 hours.');
+ }
+
+ if ($otpType->requiresApps() && $apps === []) {
+ throw new \InvalidArgumentException('WhatsApp ' . $otpType->value . ' templates must name at least one app that receives the code.');
+ }
+
+ if (\count($apps) > self::APPS_MAX) {
+ throw new \InvalidArgumentException('WhatsApp templates may name at most ' . self::APPS_MAX . ' apps.');
+ }
+
+ $components = [
+ [
+ 'type' => 'body',
+ 'add_security_recommendation' => $securityRecommendation,
+ ],
+ ];
+
+ if ($expirationMinutes !== null) {
+ $components[] = [
+ 'type' => 'footer',
+ 'code_expiration_minutes' => $expirationMinutes,
+ ];
+ }
+
+ $button = [
+ 'type' => self::BUTTON_TYPE,
+ 'otp_type' => $otpType->value,
+ ];
+
+ if ($apps !== []) {
+ $button['supported_apps'] = array_map(static fn(App $app): array => $app->toArray(), array_values($apps));
+ }
+
+ if ($otpType === OtpType::ZERO_TAP) {
+ $button['zero_tap_terms_accepted'] = true;
+ }
+
+ $components[] = [
+ 'type' => 'buttons',
+ 'buttons' => [$button],
+ ];
+
+ $body = [
+ 'name' => $this->template,
+ 'category' => self::CATEGORY,
+ 'components' => $components,
+ ];
+
+ if ($languages !== []) {
+ $body['languages'] = array_values($languages);
+ }
+
+ if ($timeToLive !== null) {
+ $body['message_send_ttl_seconds'] = $timeToLive;
+ }
+
+ $result = $this->request(
+ method: 'POST',
+ url: $this->url($businessAccountId, 'upsert_message_templates'),
+ headers: $this->headers(),
+ body: $body,
+ );
+
+ if ($result['statusCode'] < 200 || $result['statusCode'] >= 300) {
+ throw new \RuntimeException($this->error($result));
+ }
+
+ return \is_array($result['response']) ? $result['response'] : [];
+ }
+
+ /**
+ * List every language of a template with its approval status.
+ *
+ * @param string $businessAccountId WhatsApp Business Account ID that owns the template.
+ * @param string|null $name Template name; defaults to the adapter's template.
+ * @return array
+ *
+ * @throws \InvalidArgumentException If the name is not a valid template name.
+ * @throws \RuntimeException If the Graph API rejects the request.
+ */
+ public function getTemplate(string $businessAccountId, ?string $name = null): array
+ {
+ $name ??= $this->template;
+ $this->assertTemplateName($name);
+
+ $result = $this->request(
+ method: 'GET',
+ url: $this->url($businessAccountId, 'message_templates') . '?' . http_build_query([
+ 'name' => $name,
+ 'fields' => self::TEMPLATE_FIELDS,
+ ]),
+ headers: $this->headers(),
+ );
+
+ if ($result['statusCode'] < 200 || $result['statusCode'] >= 300) {
+ throw new \RuntimeException($this->error($result));
+ }
+
+ $data = \is_array($result['response']) ? ($result['response']['data'] ?? []) : [];
+
+ return \is_array($data) ? array_values($data) : [];
+ }
+
+ /**
+ * Delete a template in every language it exists in.
+ *
+ * @param string $businessAccountId WhatsApp Business Account ID that owns the template.
+ * @param string|null $name Template name; defaults to the adapter's template.
+ *
+ * @throws \InvalidArgumentException If the name is not a valid template name.
+ * @throws \RuntimeException If the Graph API rejects the request.
+ */
+ public function deleteTemplate(string $businessAccountId, ?string $name = null): void
+ {
+ $name ??= $this->template;
+ $this->assertTemplateName($name);
+
+ $result = $this->request(
+ method: 'DELETE',
+ url: $this->url($businessAccountId, 'message_templates') . '?' . http_build_query(['name' => $name]),
+ headers: $this->headers(),
+ );
+
+ if ($result['statusCode'] < 200 || $result['statusCode'] >= 300) {
+ throw new \RuntimeException($this->error($result));
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function process(SMSMessage $message): array
+ {
+ $code = $message->getContent();
+
+ if (preg_match(self::CODE_PATTERN, $code) !== 1) {
+ throw new \InvalidArgumentException('WhatsApp authentication templates only accept a code of up to ' . self::CODE_MAX_LENGTH . ' letters and digits as the message content.');
+ }
+
+ $metadata = $message->getMetadata() ?? [];
+ $metadata = array_intersect_key($metadata, array_flip(array_column(MetadataParameter::cases(), 'value')));
+
+ foreach ($metadata as $key => $value) {
+ if (!\is_string($value) || $value === '') {
+ throw new \InvalidArgumentException("WhatsApp {$key} metadata must be a non-empty string.");
+ }
+ }
+
+ $language = $metadata[MetadataParameter::LANGUAGE->value] ?? $this->language;
+ $template = $metadata[MetadataParameter::TEMPLATE->value] ?? $this->template;
+ $callbackData = $metadata[MetadataParameter::CALLBACK_DATA->value] ?? null;
+
+ $this->assertTemplateName($template);
+
+ if ($callbackData !== null && \strlen($callbackData) > self::CALLBACK_DATA_MAX_LENGTH) {
+ throw new \InvalidArgumentException('WhatsApp callback data must be at most ' . self::CALLBACK_DATA_MAX_LENGTH . ' characters.');
+ }
+
+ $to = $message->getTo()[0] ?? null;
+
+ if (!\is_string($to) || $to === '') {
+ throw new \InvalidArgumentException('WhatsApp requires exactly one recipient phone number.');
+ }
+
+ $body = [
+ 'messaging_product' => self::PRODUCT,
+ 'recipient_type' => 'individual',
+ 'to' => $this->normalize($to),
+ 'type' => 'template',
+ 'template' => [
+ 'name' => $template,
+ 'language' => [
+ 'code' => $language,
+ ],
+ 'components' => [
+ [
+ 'type' => 'body',
+ 'parameters' => [
+ [
+ 'type' => 'text',
+ 'text' => $code,
+ ],
+ ],
+ ],
+ [
+ 'type' => 'button',
+ 'sub_type' => self::BUTTON_SUB_TYPE,
+ 'index' => '0',
+ 'parameters' => [
+ [
+ 'type' => 'text',
+ 'text' => $code,
+ ],
+ ],
+ ],
+ ],
+ ],
+ ];
+
+ if ($callbackData !== null) {
+ $body['biz_opaque_callback_data'] = $callbackData;
+ }
+
+ $result = $this->request(
+ method: 'POST',
+ url: $this->url($this->phoneNumberId, 'messages'),
+ headers: $this->headers(),
+ body: $body,
+ );
+
+ $response = new Response($this->getType());
+
+ if ($result['statusCode'] >= 200 && $result['statusCode'] < 300) {
+ $response->setDeliveredTo(1);
+ $response->addResult($to);
+ } else {
+ $response->addResult($to, $this->error($result));
+ }
+
+ return $response->toArray();
+ }
+
+ /**
+ * @throws \InvalidArgumentException If the name is not lowercase letters, digits and underscores.
+ */
+ private function assertTemplateName(string $name): void
+ {
+ if (preg_match(self::TEMPLATE_NAME_PATTERN, $name) !== 1) {
+ throw new \InvalidArgumentException('WhatsApp template names must be 1 to ' . self::TEMPLATE_NAME_MAX_LENGTH . ' lowercase letters, digits and underscores.');
+ }
+ }
+
+ /**
+ * The Cloud API wants the number as digits only, country code first, without a plus sign.
+ */
+ private function normalize(string $number): string
+ {
+ return preg_replace('/\D+/', '', $number) ?? '';
+ }
+
+ private function url(string $id, string $edge): string
+ {
+ return self::ENDPOINT . '/' . $this->version . '/' . $id . '/' . $edge;
+ }
+
+ /**
+ * @return array
+ */
+ private function headers(): array
+ {
+ return [
+ 'Content-Type: application/json',
+ 'Authorization: Bearer ' . $this->accessToken,
+ ];
+ }
+
+ /**
+ * Turn a Graph API error into one line keeping the numeric code, which is
+ * what distinguishes retryable throughput limits from configuration mistakes.
+ *
+ * @param array{statusCode: int, response: array|string|null, error: string} $result
+ */
+ private function error(array $result): string
+ {
+ if ($result['statusCode'] === 0) {
+ return $result['error'] !== '' ? $result['error'] : 'Unknown error';
+ }
+
+ $error = \is_array($result['response']) ? ($result['response']['error'] ?? null) : null;
+
+ if (!\is_array($error)) {
+ return 'Unknown error';
+ }
+
+ $parts = [];
+
+ if (isset($error['code'])) {
+ $parts[] = 'Error ' . $error['code'];
+ }
+
+ if (isset($error['message']) && \is_string($error['message'])) {
+ $parts[] = $error['message'];
+ }
+
+ $details = $error['error_data']['details'] ?? null;
+
+ if (\is_string($details) && $details !== '' && !\in_array($details, $parts, true)) {
+ $parts[] = $details;
+ }
+
+ return $parts === [] ? 'Unknown error' : implode(': ', $parts);
+ }
+}
diff --git a/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp/App.php b/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp/App.php
new file mode 100644
index 000000000..f978c84d9
--- /dev/null
+++ b/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp/App.php
@@ -0,0 +1,51 @@
+ self::PACKAGE_NAME_MAX_LENGTH) {
+ throw new \InvalidArgumentException('WhatsApp app package name must be between 1 and ' . self::PACKAGE_NAME_MAX_LENGTH . ' characters.');
+ }
+
+ if (\strlen($signatureHash) !== self::SIGNATURE_HASH_LENGTH) {
+ throw new \InvalidArgumentException('WhatsApp app signature hash must be exactly ' . self::SIGNATURE_HASH_LENGTH . ' characters.');
+ }
+ }
+
+ /**
+ * @return array{package_name: string, signature_hash: string}
+ */
+ public function toArray(): array
+ {
+ return [
+ 'package_name' => $this->packageName,
+ 'signature_hash' => $this->signatureHash,
+ ];
+ }
+}
diff --git a/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp/MetadataParameter.php b/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp/MetadataParameter.php
new file mode 100644
index 000000000..8cd74db7a
--- /dev/null
+++ b/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp/MetadataParameter.php
@@ -0,0 +1,26 @@
+accessToken = getenv('TESTS_WHATSAPP_ACCESS_TOKEN') ?: '';
+ $this->phoneNumberId = getenv('TESTS_WHATSAPP_PHONE_NUMBER_ID') ?: '';
+ $this->businessAccountId = getenv('TESTS_WHATSAPP_BUSINESS_ACCOUNT_ID') ?: '';
+ $this->recipient = getenv('TESTS_WHATSAPP_RECIPIENT') ?: '';
+
+ if ($this->accessToken === '' || $this->phoneNumberId === '' || $this->businessAccountId === '' || $this->recipient === '') {
+ $this->markTestSkipped('Set TESTS_WHATSAPP_ACCESS_TOKEN, TESTS_WHATSAPP_PHONE_NUMBER_ID, TESTS_WHATSAPP_BUSINESS_ACCOUNT_ID and TESTS_WHATSAPP_RECIPIENT to run against Meta.');
+ }
+ }
+
+ public function testListsEveryLanguageOfATemplateWithItsStatus(): void
+ {
+ $adapter = new WhatsApp($this->accessToken, $this->phoneNumberId, self::TEMPLATE);
+
+ $templates = $adapter->getTemplate($this->businessAccountId, self::SAMPLE_TEMPLATE);
+
+ $this->assertNotEmpty($templates, 'Every Meta test account ships the hello_world sample template.');
+ foreach ($templates as $template) {
+ $this->assertSame(self::SAMPLE_TEMPLATE, $template['name'], 'Only the requested template may be returned.');
+ $this->assertNotSame('', $template['language'], 'Each entry is one language of the template.');
+ $this->assertNotSame('', $template['status'], 'Each entry carries its approval status.');
+ }
+ }
+
+ public function testListsNothingForUnknownTemplate(): void
+ {
+ $adapter = new WhatsApp($this->accessToken, $this->phoneNumberId, self::TEMPLATE);
+
+ $this->assertSame([], $adapter->getTemplate($this->businessAccountId));
+ }
+
+ public function testSurfacesRejectedTemplateCreationAsException(): void
+ {
+ $adapter = new WhatsApp($this->accessToken, $this->phoneNumberId, self::TEMPLATE);
+
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessageMatches('/^Error \\d+: /');
+ $adapter->upsertTemplate($this->businessAccountId, ['en_US']);
+ }
+
+ public function testReportsMissingTemplate(): void
+ {
+ $adapter = new WhatsApp($this->accessToken, $this->phoneNumberId, self::TEMPLATE);
+
+ $response = $adapter->send(new SMS([$this->recipient], '123456'));
+
+ $this->assertSame(0, $response['deliveredTo']);
+ $this->assertSame($this->recipient, $response['results'][0]['recipient']);
+ $this->assertSame('failure', $response['results'][0]['status']);
+ $this->assertStringContainsString('132001', (string) $response['results'][0]['error'], 'Meta reports an unknown template as error 132001.');
+ }
+
+ public function testOverridesTemplatePerMessage(): void
+ {
+ $adapter = new WhatsApp($this->accessToken, $this->phoneNumberId, self::SAMPLE_TEMPLATE);
+
+ $message = new SMS([$this->recipient], '123456');
+ $message->setMetadata([
+ MetadataParameter::TEMPLATE->value => self::TEMPLATE,
+ MetadataParameter::LANGUAGE->value => 'en_US',
+ MetadataParameter::CALLBACK_DATA->value => 'utopia-messaging-e2e',
+ ]);
+
+ $response = $adapter->send($message);
+
+ $this->assertSame('failure', $response['results'][0]['status']);
+ $this->assertStringContainsString('132001', (string) $response['results'][0]['error'], 'The constructor template exists, so an unknown-template error proves the override reached Meta.');
+ }
+
+ public function testReportsRecipientOutsideAllowList(): void
+ {
+ $adapter = new WhatsApp($this->accessToken, $this->phoneNumberId, self::TEMPLATE);
+
+ $response = $adapter->send(new SMS(['+15550000001'], '123456'));
+
+ $this->assertSame(0, $response['deliveredTo']);
+ $this->assertSame('failure', $response['results'][0]['status']);
+ $this->assertStringContainsString('131030', (string) $response['results'][0]['error'], 'A test number may only message allow-listed recipients.');
+ }
+
+ public function testReportsInvalidToken(): void
+ {
+ $adapter = new WhatsApp('not-a-token', $this->phoneNumberId, self::TEMPLATE);
+
+ $response = $adapter->send(new SMS([$this->recipient], '123456'));
+
+ $this->assertSame(0, $response['deliveredTo']);
+ $this->assertSame('failure', $response['results'][0]['status']);
+ $this->assertStringContainsString('190', (string) $response['results'][0]['error'], 'Meta rejects a bad bearer token with error 190.');
+ }
+}