Skip to content

feat: Add BSUID (Business-Scoped User ID) support for WhatsApp usernames#243

Closed
yHugirat wants to merge 1 commit into
netflie:mainfrom
yHugirat:feature/bsuid-support
Closed

feat: Add BSUID (Business-Scoped User ID) support for WhatsApp usernames#243
yHugirat wants to merge 1 commit into
netflie:mainfrom
yHugirat:feature/bsuid-support

Conversation

@yHugirat

Copy link
Copy Markdown

Summary

Starting March 31, 2026, WhatsApp webhooks will begin including Business-Scoped User IDs (BSUIDs) and may omit phone numbers for users who enable the new username feature. This PR makes the SDK forward-compatible with these breaking API changes while maintaining full backward compatibility.

See Meta's documentation: Business-scoped user IDs

Changes

Support\Customer

  • Made $id (wa_id) and $phone_number (from) nullable — these fields will be omitted when a user enables usernames and the phone number cannot be included
  • Added new fields: $user_id (BSUID), $username, $parent_user_id
  • Added accessor methods: userId(), username(), parentUserId()
  • Added helper methods: identifier() (returns best available ID), hasPhoneNumber(), hasBsuid()

MessageNotificationFactory

  • Safely handles missing from and wa_id fields using null coalescing (?? null)
  • Extracts new webhook fields: user_id, from_user_id, username, parent_user_id, from_parent_user_id
  • Passes all new fields to the Customer constructor

StatusNotification

  • Made $customer_id (recipient_id) nullable — will be omitted for BSUID-targeted messages
  • Added $recipient_user_id and $parent_recipient_user_id fields
  • Added methods: recipientUserId(), parentRecipientUserId(), recipientIdentifier()

StatusNotificationFactory

  • Safely handles missing recipient_id using null coalescing
  • Extracts recipient_user_id and parent_recipient_user_id from status webhooks

Backward Compatibility

All changes are fully backward-compatible:

  • Existing code using customer()->phoneNumber() continues to work (returns phone when available)
  • New nullable parameters have default values of null
  • No existing method signatures were broken — only return types changed from string to ?string where fields may now be omitted

Timeline

  • Feb 16, 2026: Test BSUIDs in App Dashboard test webhooks
  • March 31, 2026: BSUIDs in production webhooks, phone numbers may be omitted
  • May 2026: API supports sending messages to BSUIDs via new recipient field

Test plan

  • Verify existing webhook parsing works with current (phone-only) payloads
  • Test with BSUID-only payloads (no from, no wa_id)
  • Test with mixed payloads (both phone and BSUID present)
  • Verify Customer::identifier() returns phone when available, BSUID as fallback
  • Verify StatusNotification handles missing recipient_id

Starting March 31, 2026, WhatsApp webhooks will include Business-Scoped
User IDs (BSUIDs) and may omit phone numbers for users who enable the
username feature. This change makes the SDK forward-compatible with
these API changes while maintaining full backward compatibility.

Changes:
- Customer: Make phone_number and wa_id nullable, add user_id (BSUID),
  username, and parent_user_id fields with accessor methods
- MessageNotificationFactory: Safely handle missing `from` and `wa_id`
  fields, extract new `from_user_id`, `user_id`, `username`, and
  `parent_user_id` from webhook payloads
- StatusNotification: Make recipient_id nullable, add recipient_user_id
  and parent_recipient_user_id fields
- StatusNotificationFactory: Safely handle missing `recipient_id`,
  extract `recipient_user_id` and `parent_recipient_user_id`

All changes are backward-compatible - existing phone-based flows work
unchanged. New BSUID fields are captured when present in webhooks.

See: https://developers.facebook.com/docs/whatsapp/business-management/business-scoped-user-ids

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the webhook notification layer to support WhatsApp’s upcoming Business-Scoped User IDs (BSUIDs) / username-enabled users, where phone-number-based identifiers may be omitted, while aiming to keep backward compatibility for existing payloads.

Changes:

  • Extend Support\Customer to accept nullable phone-based identifiers and add BSUID/username fields plus helper accessors.
  • Extend status notifications to allow missing recipient_id and surface BSUID recipient identifiers.
  • Update message/status factories to extract BSUID-related fields and tolerate missing legacy fields.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.

File Description
src/WebHook/Notification/Support/Customer.php Makes legacy IDs nullable; adds BSUID/username fields + helper methods.
src/WebHook/Notification/MessageNotificationFactory.php Extracts BSUID/username fields and allows missing from/wa_id when building Customer.
src/WebHook/Notification/StatusNotification.php Makes customer_id nullable; adds recipient BSUID fields + identifier helper.
src/WebHook/Notification/StatusNotificationFactory.php Allows missing recipient_id and passes recipient BSUID fields into StatusNotification.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +131 to 147
// BSUID support: wa_id and from may be omitted for username-enabled users.
// Fall back gracefully when phone number fields are missing.
$waId = $contact['wa_id'] ?? null;
$fromPhone = $message['from'] ?? null;
$userId = $contact['user_id'] ?? $message['from_user_id'] ?? null;
$username = $contact['profile']['username'] ?? null;
$parentUserId = $contact['parent_user_id'] ?? $message['from_parent_user_id'] ?? null;
$profileName = $contact['profile']['name'] ?? '';

$notification->withCustomer(new Support\Customer(
$contact['wa_id'],
$contact['profile']['name'] ?? '',
$message['from']
$waId,
$profileName,
$fromPhone,
$userId,
$username,
$parentUserId
));

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new BSUID/username extraction logic isn’t covered by the existing webhook parsing unit tests (e.g., cases where from/wa_id are omitted but from_user_id/user_id are present). Add unit tests for BSUID-only and mixed payloads to prevent regressions and ensure Customer is populated as intended.

Copilot uses AI. Check for mistakes.
Comment on lines +75 to +79
* Get the best available identifier: phone number or BSUID.
*/
public function identifier(): string
{
return $this->phone_number ?? $this->user_id ?? $this->id ?? '';

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Customer::identifier() is declared to return string but will return an empty string when none of phone_number, user_id, or id are present. An empty string is not a meaningful identifier and can mask missing data; consider returning ?string (null when unavailable) or throwing when no identifier exists. Also, the docblock says “phone number or BSUID” but the implementation can return the legacy wa_id too—update the wording to match the actual precedence.

Suggested change
* Get the best available identifier: phone number or BSUID.
*/
public function identifier(): string
{
return $this->phone_number ?? $this->user_id ?? $this->id ?? '';
* Get the best available identifier, in order of precedence: phone number, BSUID, then legacy WA ID.
*
* @return string|null The identifier, or null if none are available.
*/
public function identifier(): ?string
{
return $this->phone_number ?? $this->user_id ?? $this->id;

Copilot uses AI. Check for mistakes.
Comment on lines +9 to 23
// BSUID support: recipient_id may be omitted for username-enabled users.
// Fall back to recipient_user_id when phone-based recipient_id is missing.
$recipientId = $status['recipient_id'] ?? null;
$recipientUserId = $status['recipient_user_id'] ?? null;
$parentRecipientUserId = $status['parent_recipient_user_id'] ?? null;

$notification = new StatusNotification(
$status['id'],
new Support\Business($metadata['phone_number_id'], $metadata['display_phone_number']),
$status['recipient_id'],
$recipientId,
$status['status'],
$status['timestamp']
$status['timestamp'],
$recipientUserId,
$parentRecipientUserId
);

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new BSUID fields (recipient_user_id, parent_recipient_user_id) and nullable recipient_id handling are not exercised by current unit tests (no status payloads assert these fields). Add a status webhook test where recipient_id is omitted but recipient_user_id is present, and assert the new accessors / recipientIdentifier() behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +85 to +87
public function recipientIdentifier(): string
{
return $this->customer_id ?? $this->recipient_user_id ?? '';

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StatusNotification::recipientIdentifier() returns an empty string when both customer_id and recipient_user_id are null. As with Customer::identifier(), an empty string can silently propagate invalid identifiers; consider returning ?string (null when unavailable) or enforcing that at least one identifier must be present.

Suggested change
public function recipientIdentifier(): string
{
return $this->customer_id ?? $this->recipient_user_id ?? '';
public function recipientIdentifier(): ?string
{
return $this->customer_id ?? $this->recipient_user_id;

Copilot uses AI. Check for mistakes.
Comment on lines 38 to 47
return new Media(
$message['id'],
new Support\Business($metadata['phone_number_id'], $metadata['display_phone_number']),
$message[$message['type']]['id'],
$message[$message['type']]['mime_type'],
$message[$message['type']]['sha256'],
$message[$message['type']]['filename'] ?? '',
$message[$message['type']]['caption'] ?? '',
new MediaType($message['type']),
$message['timestamp']
);

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Media's constructor requires a MediaType $type argument (see src/WebHook/Notification/Media.php), but the factory no longer passes it. This will throw an ArgumentCountError/type error when building media notifications. Pass the appropriate MediaType instance again (e.g., derived from $message['type']).

Copilot uses AI. Check for mistakes.
@@ -149,7 +158,6 @@
$notification->withContext(new Support\Context(
$message['context']['id'] ?? null,
$message['context']['forwarded'] ?? false,

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Support\Context's constructor signature is (replying_to_message_id, forwarded, frequently_forwarded, referred_product), but the factory now passes only 3 arguments. As written, $referred_product is being passed into the bool $frequently_forwarded parameter, which will raise a TypeError when a referred product exists and will drop the actual referred_product entirely. Include the frequently_forwarded argument (or explicitly pass false) before $referred_product.

Suggested change
$message['context']['forwarded'] ?? false,
$message['context']['forwarded'] ?? false,
$message['context']['frequently_forwarded'] ?? false,

Copilot uses AI. Check for mistakes.
$message['referral']['image_url'] ?? $message['referral']['video_url'] ?? '',
$message['referral']['thumbnail_url'] ?? '',
$message['referral']['ctwa_clid'] ?? ''
$message['referral']['thumbnail_url'] ?? ''

Copilot AI Mar 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Support\Referral's constructor still requires the final ctwa_clid argument (see src/WebHook/Notification/Support/Referral.php), but the factory call was reduced to 8 parameters. This will throw an ArgumentCountError when a referral is present. Pass ctwa_clid (or a default like an empty string) as the last argument.

Suggested change
$message['referral']['thumbnail_url'] ?? ''
$message['referral']['thumbnail_url'] ?? '',
$message['referral']['ctwa_clid'] ?? ''

Copilot uses AI. Check for mistakes.
@aalbarca

Copy link
Copy Markdown
Contributor

Hi @yHugirat , thanks for your pull request.

You removed some variables and lines of code, but I don't quite understand why.

GitHub Copilot (AI) also left some comments. Please be careful with AI comments, as it can make mistakes in its suggestions.

The unit tests fail.

Thanks

use Netflie\WhatsAppCloudApi\Message\Media\MediaType;

final class MessageNotificationFactory
class MessageNotificationFactory

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why did you remove final class?

$notification->withContext(new Support\Context(
$message['context']['id'] ?? null,
$message['context']['forwarded'] ?? false,
$message['context']['frequently_forwarded'] ?? false,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why did you remove frequently_forwarded?

$message[$message['type']]['sha256'],
$message[$message['type']]['filename'] ?? '',
$message[$message['type']]['caption'] ?? '',
new MediaType($message['type']),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why did you remove this?

$message['referral']['media_type'] ?? '',
$message['referral']['image_url'] ?? $message['referral']['video_url'] ?? '',
$message['referral']['thumbnail_url'] ?? '',
$message['referral']['ctwa_clid'] ?? ''

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why did you remove this?

@aalbarca

aalbarca commented Apr 14, 2026

Copy link
Copy Markdown
Contributor

Done here #245

@aalbarca aalbarca closed this Apr 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants