diff --git a/api/pom.xml b/api/pom.xml
index d9c1f27..b64ee8b 100644
--- a/api/pom.xml
+++ b/api/pom.xml
@@ -4,7 +4,7 @@
com.messagebird
messagebird-api
- 6.3.1
+ 6.4.0
jar
${project.groupId}:${project.artifactId}
diff --git a/api/src/main/java/com/messagebird/objects/conversations/ConversationContent.java b/api/src/main/java/com/messagebird/objects/conversations/ConversationContent.java
index 9ba82bd..8b4fe16 100644
--- a/api/src/main/java/com/messagebird/objects/conversations/ConversationContent.java
+++ b/api/src/main/java/com/messagebird/objects/conversations/ConversationContent.java
@@ -1,9 +1,16 @@
package com.messagebird.objects.conversations;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
/**
* ConversationContent wraps actual content. The field that should be set here
* is indicated by ConversationContentType.
+ *
+ *
Unknown keys are ignored on deserialization so that consumers parsing
+ * webhook payloads in their own handlers are not broken by content fields added
+ * after their SDK version. Serialization is unaffected.
*/
+@JsonIgnoreProperties(ignoreUnknown = true)
public class ConversationContent {
private ConversationContentMedia audio;
diff --git a/api/src/main/java/com/messagebird/objects/conversations/ConversationMessageMetadata.java b/api/src/main/java/com/messagebird/objects/conversations/ConversationMessageMetadata.java
index 7d908e6..c41e814 100644
--- a/api/src/main/java/com/messagebird/objects/conversations/ConversationMessageMetadata.java
+++ b/api/src/main/java/com/messagebird/objects/conversations/ConversationMessageMetadata.java
@@ -1,5 +1,7 @@
package com.messagebird.objects.conversations;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
import java.util.Date;
/**
@@ -8,6 +10,7 @@
* the BSUID when Meta provides one. When both identifiers exist, the phone
* number appears in the parent {@code from} field, not in this object.
*/
+@JsonIgnoreProperties(ignoreUnknown = true)
public class ConversationMessageMetadata {
private ConversationSenderMetadata sender;
diff --git a/api/src/main/java/com/messagebird/objects/conversations/ConversationRecipientMetadata.java b/api/src/main/java/com/messagebird/objects/conversations/ConversationRecipientMetadata.java
new file mode 100644
index 0000000..7537da8
--- /dev/null
+++ b/api/src/main/java/com/messagebird/objects/conversations/ConversationRecipientMetadata.java
@@ -0,0 +1,50 @@
+package com.messagebird.objects.conversations;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+/**
+ * Identifies the recipient of an outbound WhatsApp message, as reported back on
+ * status webhook payloads under {@code status.metadata.recipient}. Mirrors
+ * {@link ConversationSenderMetadata} on the inbound side.
+ *
+ *
{@code userId} is the recipient's BSUID (e.g. "US.13491208655302741918");
+ * {@code parentUserId} is the parent business-scoped user ID of the enterprise
+ * that owns the business portfolio it was scoped against (e.g.
+ * "US.ENT.11815799212886844830").
+ *
+ *
Either field may be {@code null}: Meta only supplies them for accounts
+ * enrolled in the BSUID rollout, and the enclosing {@code recipient} object is
+ * omitted entirely when neither is present. This is the only place a status
+ * payload carries the recipient's own identity — {@code messageMetadata.to} is
+ * an echo of the address the message was addressed to.
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ConversationRecipientMetadata {
+
+ private String userId;
+ private String parentUserId;
+
+ public String getUserId() {
+ return userId;
+ }
+
+ public void setUserId(String userId) {
+ this.userId = userId;
+ }
+
+ public String getParentUserId() {
+ return parentUserId;
+ }
+
+ public void setParentUserId(String parentUserId) {
+ this.parentUserId = parentUserId;
+ }
+
+ @Override
+ public String toString() {
+ return "ConversationRecipientMetadata{" +
+ "userId='" + userId + '\'' +
+ ", parentUserId='" + parentUserId + '\'' +
+ '}';
+ }
+}
diff --git a/api/src/main/java/com/messagebird/objects/conversations/ConversationSenderMetadata.java b/api/src/main/java/com/messagebird/objects/conversations/ConversationSenderMetadata.java
index 33d7e22..b7fdcbb 100644
--- a/api/src/main/java/com/messagebird/objects/conversations/ConversationSenderMetadata.java
+++ b/api/src/main/java/com/messagebird/objects/conversations/ConversationSenderMetadata.java
@@ -1,14 +1,24 @@
package com.messagebird.objects.conversations;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
/**
* Metadata about the sender of a WhatsApp message. {@code userId} always
* contains the BSUID (e.g. "US.13491208655302741918") when Meta supplies one.
* When both a phone number and a BSUID are available, the phone number appears
* in the parent message's {@code from} field — not here.
+ *
+ *
{@code parentUserId} carries the sender's parent business-scoped user ID
+ * (e.g. "US.ENT.11815799212886844830"), which identifies the enterprise that
+ * owns the business portfolio the {@code userId} was scoped against. It is only
+ * present for accounts enrolled in Meta's parent-BSUID rollout; for everyone
+ * else it stays {@code null}.
*/
+@JsonIgnoreProperties(ignoreUnknown = true)
public class ConversationSenderMetadata {
private String userId;
+ private String parentUserId;
private String username;
private String displayName;
@@ -20,6 +30,14 @@ public void setUserId(String userId) {
this.userId = userId;
}
+ public String getParentUserId() {
+ return parentUserId;
+ }
+
+ public void setParentUserId(String parentUserId) {
+ this.parentUserId = parentUserId;
+ }
+
public String getUsername() {
return username;
}
@@ -40,6 +58,7 @@ public void setDisplayName(String displayName) {
public String toString() {
return "ConversationSenderMetadata{" +
"userId='" + userId + '\'' +
+ ", parentUserId='" + parentUserId + '\'' +
", username='" + username + '\'' +
", displayName='" + displayName + '\'' +
'}';
diff --git a/api/src/main/java/com/messagebird/objects/conversations/ConversationStatusMessageMetadata.java b/api/src/main/java/com/messagebird/objects/conversations/ConversationStatusMessageMetadata.java
index df7feeb..47a4a8d 100644
--- a/api/src/main/java/com/messagebird/objects/conversations/ConversationStatusMessageMetadata.java
+++ b/api/src/main/java/com/messagebird/objects/conversations/ConversationStatusMessageMetadata.java
@@ -1,5 +1,7 @@
package com.messagebird.objects.conversations;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
/**
* The {@code messageMetadata} block delivered inside status webhook payloads
* (e.g. {@code statusSent}, {@code statusDelivered}). Reflects the original
@@ -12,7 +14,13 @@
*
Both {@code from} and {@code to} accept either a phone number or a
* WhatsApp Business-Scoped User ID (BSUID, e.g. "US.13491208655302741918").
* The BSUID is also available via {@code metadata.sender.userId}.
+ *
+ *
{@code to} echoes back the address the message was originally addressed
+ * to, so it is not a reliable source of the recipient's BSUID. That identity
+ * lives alongside this block, under {@code status.metadata.recipient} — see
+ * {@link ConversationStatusMetadata}.
*/
+@JsonIgnoreProperties(ignoreUnknown = true)
public class ConversationStatusMessageMetadata {
private String id;
diff --git a/api/src/main/java/com/messagebird/objects/conversations/ConversationStatusMetadata.java b/api/src/main/java/com/messagebird/objects/conversations/ConversationStatusMetadata.java
new file mode 100644
index 0000000..0b4d5f7
--- /dev/null
+++ b/api/src/main/java/com/messagebird/objects/conversations/ConversationStatusMetadata.java
@@ -0,0 +1,85 @@
+package com.messagebird.objects.conversations;
+
+import com.fasterxml.jackson.annotation.JsonAnyGetter;
+import com.fasterxml.jackson.annotation.JsonAnySetter;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * The {@code status.metadata} block delivered inside status webhook payloads
+ * (e.g. {@code statusSent}, {@code statusDelivered}).
+ *
+ *
This class is not produced by any SDK request — it is a standalone POJO
+ * intended for consumers who deserialize incoming webhook payloads in their own
+ * HTTP handlers. Use it via {@code ObjectMapper.readValue(body, ...)}.
+ *
+ *
Note the mixed casing of this object. {@code pricing} and
+ * {@code conversation} are near-verbatim passthroughs of Meta's own objects and
+ * so keep their snake_case keys ({@code pricing_model}, {@code category}, …);
+ * they are exposed here as raw maps rather than modelled types, because their
+ * contents track Meta's schema rather than ours. {@code recipient} is ours and
+ * follows the camelCase convention used everywhere else in the API. Any other
+ * key present on the payload — for example {@code biz_opaque_callback_data} —
+ * is collected into {@link #getAdditionalProperties()} rather than dropped.
+ *
+ *
{@code recipient} is absent from payloads for accounts that never receive
+ * BSUIDs, in which case {@link #getRecipient()} returns {@code null}.
+ */
+public class ConversationStatusMetadata {
+
+ private Map pricing;
+ private Map conversation;
+ private ConversationRecipientMetadata recipient;
+ private final Map additionalProperties = new LinkedHashMap<>();
+
+ public Map getPricing() {
+ return pricing;
+ }
+
+ public void setPricing(Map pricing) {
+ this.pricing = pricing;
+ }
+
+ public Map getConversation() {
+ return conversation;
+ }
+
+ public void setConversation(Map conversation) {
+ this.conversation = conversation;
+ }
+
+ public ConversationRecipientMetadata getRecipient() {
+ return recipient;
+ }
+
+ public void setRecipient(ConversationRecipientMetadata recipient) {
+ this.recipient = recipient;
+ }
+
+ /**
+ * Every key on the payload that has no dedicated accessor above, in the
+ * order it was encountered. Empty when the payload holds nothing else.
+ *
+ * @return the unmodelled remainder of the metadata object
+ */
+ @JsonAnyGetter
+ public Map getAdditionalProperties() {
+ return additionalProperties;
+ }
+
+ @JsonAnySetter
+ public void setAdditionalProperty(String name, Object value) {
+ additionalProperties.put(name, value);
+ }
+
+ @Override
+ public String toString() {
+ return "ConversationStatusMetadata{" +
+ "pricing=" + pricing +
+ ", conversation=" + conversation +
+ ", recipient=" + recipient +
+ ", additionalProperties=" + additionalProperties +
+ '}';
+ }
+}
diff --git a/api/src/test/java/com/messagebird/ConversationMessagesTest.java b/api/src/test/java/com/messagebird/ConversationMessagesTest.java
index 5bd0a5c..beac543 100644
--- a/api/src/test/java/com/messagebird/ConversationMessagesTest.java
+++ b/api/src/test/java/com/messagebird/ConversationMessagesTest.java
@@ -1,6 +1,5 @@
package com.messagebird;
-import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.messagebird.exceptions.GeneralException;
import com.messagebird.exceptions.NotFoundException;
@@ -25,9 +24,20 @@ public class ConversationMessagesTest {
private static final String JSON_CONVERSATION_MESSAGE_TEXT = "{\"id\": \"mesid\",\"conversationId\": \"convid\",\"channelId\": \"chanid\",\"status\": \"received\",\"type\": \"text\",\"direction\": \"received\",\"content\": {\"text\": \"Hello\"},\"createdDatetime\": \"2018-08-29T11:49:16Z\",\"updatedDatetime\": \"2018-08-29T11:49:16Z\"}";
private static final String JSON_CONVERSATION_MESSAGE_VIDEO = "{\"id\": \"mesid\",\"conversationId\": \"convid\",\"channelId\": \"chanid\",\"status\": \"received\",\"type\": \"video\",\"direction\": \"received\",\"content\": {\"video\": { \"url\": \"https://example.com/video.mp4\" } },\"createdDatetime\": \"2018-08-29T11:49:16Z\",\"updatedDatetime\": \"2018-08-29T11:49:16Z\"}";
private static final String JSON_CONVERSATION_SEND_MESSAGE_RESPONSE = "{\"id\":\"mesid\",\"status\":\"accepted\",\"fallback\":{\"id\":\"mesid\"}}";
- private static final String JSON_CONVERSATION_MESSAGE_BSUID = "{\"id\": \"mesid\",\"conversationId\": \"convid\",\"channelId\": \"chanid\",\"status\": \"received\",\"type\": \"text\",\"direction\": \"received\",\"content\": {\"text\": \"Hello\"},\"metadata\": {\"sender\": {\"displayName\": \"Alice\",\"username\": \"alice_shop\",\"userId\": \"US.13491208655302741918\"},\"receivedAt\": \"2025-04-15T16:00:00Z\"},\"createdDatetime\": \"2025-04-15T16:00:00Z\",\"updatedDatetime\": \"2025-04-15T16:00:00Z\"}";
+ private static final String JSON_CONVERSATION_MESSAGE_BSUID = "{\"id\": \"mesid\",\"conversationId\": \"convid\",\"channelId\": \"chanid\",\"status\": \"received\",\"type\": \"text\",\"direction\": \"received\",\"content\": {\"text\": \"Hello\"},\"metadata\": {\"sender\": {\"displayName\": \"Alice\",\"username\": \"alice_shop\",\"userId\": \"US.13491208655302741918\",\"parentUserId\": \"US.ENT.11815799212886844830\"},\"receivedAt\": \"2025-04-15T16:00:00Z\"},\"createdDatetime\": \"2025-04-15T16:00:00Z\",\"updatedDatetime\": \"2025-04-15T16:00:00Z\"}";
private static final String JSON_STATUS_MESSAGE_METADATA = "{\"id\": \"e5f6a7b8-c9d0-1234-ef01-23456789abcd\",\"from\": \"15551234567\",\"to\": \"US.13491208655302741918\",\"type\": \"text\",\"content\": {\"text\": \"Hello! Your order has been shipped.\"},\"metadata\": {\"sender\": {\"userId\": \"US.13491208655302741918\"},\"receivedAt\": \"0001-01-01T00:00:00Z\"}}";
+ private static final String JSON_STATUS_METADATA_WITH_RECIPIENT = "{\"pricing\": {\"billable\": true,\"pricing_model\": \"CBP\",\"category\": \"utility\"},\"conversation\": {\"id\": \"a1b2c3d4\",\"origin\": {\"type\": \"utility\"}},\"biz_opaque_callback_data\": \"order-1234\",\"recipient\": {\"userId\": \"US.13491208655302741918\",\"parentUserId\": \"US.ENT.11815799212886844830\"}}";
+ private static final String JSON_STATUS_METADATA_WITHOUT_RECIPIENT = "{\"pricing\": {\"billable\": true,\"pricing_model\": \"CBP\",\"category\": \"utility\"},\"conversation\": {\"id\": \"a1b2c3d4\",\"origin\": {\"type\": \"utility\"}}}";
+ private static final String JSON_STATUS_METADATA_PARENT_ONLY = "{\"recipient\": {\"parentUserId\": \"US.ENT.11815799212886844830\"}}";
+
+ /**
+ * The same payload as JSON_STATUS_MESSAGE_METADATA with an unrecognised key
+ * added at every nesting level, standing in for fields the platform adds
+ * after this SDK version ships.
+ */
+ private static final String JSON_STATUS_MESSAGE_METADATA_UNKNOWN_FIELDS = "{\"id\": \"e5f6a7b8-c9d0-1234-ef01-23456789abcd\",\"from\": \"15551234567\",\"to\": \"US.13491208655302741918\",\"type\": \"text\",\"futureTopLevelField\": \"ignored\",\"content\": {\"text\": \"Hello! Your order has been shipped.\",\"futureContentField\": \"ignored\"},\"metadata\": {\"sender\": {\"userId\": \"US.13491208655302741918\",\"futureSenderField\": \"ignored\"},\"receivedAt\": \"0001-01-01T00:00:00Z\",\"futureMetadataField\": \"ignored\"}}";
+
/**
* Epsilon to use when checking two latitudes or longitudes for equality.
*/
@@ -204,14 +214,69 @@ public void testViewConversationMessageWithBsuidMetadata() throws GeneralExcepti
assertEquals("Alice", sender.getDisplayName());
assertEquals("alice_shop", sender.getUsername());
assertEquals("US.13491208655302741918", sender.getUserId());
+ assertEquals("US.ENT.11815799212886844830", sender.getParentUserId());
}
@Test
- public void testStatusMessageMetadataDeserializes() throws Exception {
- ObjectMapper mapper = new ObjectMapper();
- mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
+ public void testStatusMessageMetadataToleratesUnknownFields() throws Exception {
+ // A plain mapper, and unknown keys at every level: webhook payload POJOs
+ // must not force consumers to disable FAIL_ON_UNKNOWN_PROPERTIES, and
+ // must survive fields the platform adds after this version ships.
+ ConversationStatusMessageMetadata md = new ObjectMapper().readValue(
+ JSON_STATUS_MESSAGE_METADATA_UNKNOWN_FIELDS, ConversationStatusMessageMetadata.class);
- ConversationStatusMessageMetadata md = mapper.readValue(
+ assertEquals("e5f6a7b8-c9d0-1234-ef01-23456789abcd", md.getId());
+ assertEquals("Hello! Your order has been shipped.", md.getContent().getText());
+ assertEquals("US.13491208655302741918", md.getMetadata().getSender().getUserId());
+ assertNotNull(md.getMetadata().getReceivedAt());
+ }
+
+ @Test
+ public void testStatusMetadataDeserializesRecipient() throws Exception {
+ // A plain mapper: these payload POJOs must not require the caller to
+ // disable FAIL_ON_UNKNOWN_PROPERTIES.
+ ConversationStatusMetadata metadata = new ObjectMapper().readValue(
+ JSON_STATUS_METADATA_WITH_RECIPIENT, ConversationStatusMetadata.class);
+
+ ConversationRecipientMetadata recipient = metadata.getRecipient();
+ assertNotNull(recipient);
+ assertEquals("US.13491208655302741918", recipient.getUserId());
+ assertEquals("US.ENT.11815799212886844830", recipient.getParentUserId());
+
+ // Meta's own objects pass through untouched, snake_case keys and all.
+ assertEquals("CBP", metadata.getPricing().get("pricing_model"));
+ assertEquals("a1b2c3d4", metadata.getConversation().get("id"));
+
+ // Anything else on the payload is kept rather than dropped.
+ assertEquals("order-1234", metadata.getAdditionalProperties().get("biz_opaque_callback_data"));
+ }
+
+ @Test
+ public void testStatusMetadataWithoutRecipientIsNull() throws Exception {
+ ConversationStatusMetadata metadata = new ObjectMapper().readValue(
+ JSON_STATUS_METADATA_WITHOUT_RECIPIENT, ConversationStatusMetadata.class);
+
+ // Accounts that never receive BSUIDs get payloads with no recipient key
+ // at all — the rest of the metadata must still parse.
+ assertNull(metadata.getRecipient());
+ assertEquals("CBP", metadata.getPricing().get("pricing_model"));
+ assertTrue(metadata.getAdditionalProperties().isEmpty());
+ }
+
+ @Test
+ public void testStatusMetadataRecipientWithParentOnly() throws Exception {
+ ConversationStatusMetadata metadata = new ObjectMapper().readValue(
+ JSON_STATUS_METADATA_PARENT_ONLY, ConversationStatusMetadata.class);
+
+ ConversationRecipientMetadata recipient = metadata.getRecipient();
+ assertNotNull(recipient);
+ assertNull(recipient.getUserId());
+ assertEquals("US.ENT.11815799212886844830", recipient.getParentUserId());
+ }
+
+ @Test
+ public void testStatusMessageMetadataDeserializes() throws Exception {
+ ConversationStatusMessageMetadata md = new ObjectMapper().readValue(
JSON_STATUS_MESSAGE_METADATA, ConversationStatusMessageMetadata.class);
assertEquals("e5f6a7b8-c9d0-1234-ef01-23456789abcd", md.getId());
diff --git a/examples/pom.xml b/examples/pom.xml
index 9ffe9f1..cb9b748 100644
--- a/examples/pom.xml
+++ b/examples/pom.xml
@@ -6,7 +6,7 @@
com.messagebird
examples
- 6.3.1
+ 6.4.0
@@ -20,7 +20,7 @@
com.messagebird
messagebird-api
- 6.3.1
+ 6.4.0
compile