feat(whatsapp): 🎉 Add extended sending and template management
Some checks failed
CI / Test (1.23) (push) Failing after -24m15s
CI / Test (1.22) (push) Failing after -24m12s
CI / Build (push) Successful in -26m47s
CI / Lint (push) Successful in -26m36s

* Implemented new endpoints for sending various message types:
  - Audio
  - Sticker
  - Location
  - Contacts
  - Interactive messages
  - Template messages
  - Flow messages
  - Reactions
  - Marking messages as read

* Added template management endpoints:
  - List templates
  - Upload templates
  - Delete templates

* Introduced flow management endpoints:
  - List flows
  - Create flows
  - Get flow details
  - Upload flow assets
  - Publish flows
  - Delete flows

* Added phone number management endpoints:
  - List phone numbers
  - Request verification code
  - Verify code

* Enhanced media management with delete media endpoint.

* Updated dependencies.
This commit is contained in:
Hein
2026-02-03 18:07:42 +02:00
parent 98fc28fc5f
commit a7a5831911
16 changed files with 2024 additions and 48 deletions

View File

@@ -52,58 +52,78 @@ func (c *Client) HandleWebhook(r *http.Request) error {
return nil
}
// processChange processes a webhook change
// processChange processes a webhook change.
// change.Value is json.RawMessage because Meta uses different payload shapes per field type.
// Each case unmarshals into the appropriate struct.
func (c *Client) processChange(change WebhookChange) {
ctx := context.Background()
logging.Info("Processing webhook change",
"account_id", c.id,
"field", change.Field,
"phone_number_id", change.Value.Metadata.PhoneNumberID)
// Handle different field types
switch change.Field {
case "messages":
// Process messages
for i := range change.Value.Messages {
msg := change.Value.Messages[i]
c.processMessage(ctx, msg, change.Value.Contacts)
var value WebhookValue
if err := json.Unmarshal(change.Value, &value); err != nil {
logging.Error("Failed to parse messages webhook value",
"account_id", c.id, "error", err)
return
}
// Process statuses
for _, status := range change.Value.Statuses {
logging.Info("Processing webhook change",
"account_id", c.id,
"field", change.Field,
"phone_number_id", value.Metadata.PhoneNumberID)
for i := range value.Messages {
c.processMessage(ctx, value.Messages[i], value.Contacts)
}
for _, status := range value.Statuses {
c.processStatus(ctx, status)
}
case "message_template_status_update":
// Log template status updates for visibility
logging.Info("Message template status update received",
"account_id", c.id,
"phone_number_id", change.Value.Metadata.PhoneNumberID)
var value TemplateStatusValue
if err := json.Unmarshal(change.Value, &value); err != nil {
logging.Error("Failed to parse template status update",
"account_id", c.id, "error", err)
return
}
case "account_update":
// Log account updates
logging.Info("Account update received",
logging.Info("Template status update received",
"account_id", c.id,
"phone_number_id", change.Value.Metadata.PhoneNumberID)
"template_name", value.TemplateName,
"template_id", value.TemplateID,
"language", value.Language,
"status", value.Status,
"rejection_reasons", value.RejectionReasons)
case "phone_number_quality_update":
// Log quality updates
logging.Info("Phone number quality update received",
"account_id", c.id,
"phone_number_id", change.Value.Metadata.PhoneNumberID)
c.eventBus.Publish(events.TemplateStatusUpdateEvent(
ctx,
c.id,
value.TemplateName,
value.TemplateID,
value.Language,
value.Status,
value.RejectionReasons,
))
case "phone_number_name_update":
// Log name updates
logging.Info("Phone number name update received",
"account_id", c.id,
"phone_number_id", change.Value.Metadata.PhoneNumberID)
case "account_update", "phone_number_quality_update", "phone_number_name_update", "account_alerts":
// These all carry the standard WebhookValue with metadata
var value WebhookValue
if err := json.Unmarshal(change.Value, &value); err != nil {
logging.Error("Failed to parse webhook value",
"account_id", c.id, "field", change.Field, "error", err)
return
}
case "account_alerts":
// Log account alerts
logging.Warn("Account alert received",
"account_id", c.id,
"phone_number_id", change.Value.Metadata.PhoneNumberID)
if change.Field == "account_alerts" {
logging.Warn("Account alert received",
"account_id", c.id,
"phone_number_id", value.Metadata.PhoneNumberID)
} else {
logging.Info("Webhook notification received",
"account_id", c.id,
"field", change.Field,
"phone_number_id", value.Metadata.PhoneNumberID)
}
default:
logging.Debug("Unknown webhook field type",

View File

@@ -0,0 +1,71 @@
package businessapi
import (
"context"
"net/url"
)
// ListFlows returns all flows for the business account.
func (c *Client) ListFlows(ctx context.Context) (*FlowListResponse, error) {
if c.config.BusinessAccountID == "" {
return nil, errNoBusinessAccount
}
params := url.Values{
"fields": {"id,name,status,categories,created_at,updated_at,endpoint_url,preview_url,signed_preview_url,signed_flow_url"},
}
var resp FlowListResponse
if err := c.graphAPIGet(ctx, c.config.BusinessAccountID+"/flows", params, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// CreateFlow creates a new flow and returns its ID.
func (c *Client) CreateFlow(ctx context.Context, flow FlowCreateRequest) (*FlowCreateResponse, error) {
if c.config.BusinessAccountID == "" {
return nil, errNoBusinessAccount
}
var resp FlowCreateResponse
if err := c.graphAPIPost(ctx, c.config.BusinessAccountID+"/flows", flow, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// GetFlow returns details for a single flow.
func (c *Client) GetFlow(ctx context.Context, flowID string) (*FlowInfo, error) {
params := url.Values{
"fields": {"id,name,status,categories,created_at,updated_at,endpoint_url,preview_url,signed_preview_url,signed_flow_url"},
}
var resp FlowInfo
if err := c.graphAPIGet(ctx, flowID, params, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// UpdateFlowAssets uploads a screens JSON definition to an existing flow (must be in DRAFT).
func (c *Client) UpdateFlowAssets(ctx context.Context, flowID string, screensJSON string) error {
fields := map[string]string{
"asset_type": "SCREENS_JSON",
"asset_data": screensJSON,
}
var resp FlowActionResponse
return c.graphAPIPostForm(ctx, flowID+"/assets", fields, &resp)
}
// PublishFlow transitions a DRAFT flow to PUBLISHED.
func (c *Client) PublishFlow(ctx context.Context, flowID string) error {
var resp FlowActionResponse
return c.graphAPIPost(ctx, flowID+"?action=PUBLISH", nil, &resp)
}
// DeleteFlow permanently removes a flow.
func (c *Client) DeleteFlow(ctx context.Context, flowID string) error {
return c.graphAPIDelete(ctx, flowID, nil)
}

View File

@@ -0,0 +1,171 @@
package businessapi
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
)
// graphAPIGet performs an authenticated GET to the Graph API and unmarshals the response.
func (c *Client) graphAPIGet(ctx context.Context, path string, params url.Values, result any) error {
u := fmt.Sprintf("https://graph.facebook.com/%s/%s", c.config.APIVersion, path)
if len(params) > 0 {
u += "?" + params.Encode()
}
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
return c.executeRequest(req, result)
}
// graphAPIPost performs an authenticated POST with a JSON body.
// body may be nil for action-only endpoints (e.g. publish a flow).
func (c *Client) graphAPIPost(ctx context.Context, path string, body any, result any) error {
u := fmt.Sprintf("https://graph.facebook.com/%s/%s", c.config.APIVersion, path)
var reqBody io.Reader
if body != nil {
jsonData, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to marshal request body: %w", err)
}
reqBody = bytes.NewBuffer(jsonData)
}
req, err := http.NewRequestWithContext(ctx, "POST", u, reqBody)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
return c.executeRequest(req, result)
}
// graphAPIPostForm performs an authenticated POST with multipart form fields.
func (c *Client) graphAPIPostForm(ctx context.Context, path string, fields map[string]string, result any) error {
u := fmt.Sprintf("https://graph.facebook.com/%s/%s", c.config.APIVersion, path)
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
for k, v := range fields {
if err := writer.WriteField(k, v); err != nil {
return fmt.Errorf("failed to write form field %s: %w", k, err)
}
}
if err := writer.Close(); err != nil {
return fmt.Errorf("failed to close multipart writer: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", u, &buf)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
req.Header.Set("Content-Type", writer.FormDataContentType())
return c.executeRequest(req, result)
}
// graphAPIDelete performs an authenticated DELETE request.
func (c *Client) graphAPIDelete(ctx context.Context, path string, params url.Values) error {
u := fmt.Sprintf("https://graph.facebook.com/%s/%s", c.config.APIVersion, path)
if len(params) > 0 {
u += "?" + params.Encode()
}
req, err := http.NewRequestWithContext(ctx, "DELETE", u, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
return c.executeRequest(req, nil)
}
// executeRequest runs an HTTP request, handles error responses, and optionally unmarshals the body.
func (c *Client) executeRequest(req *http.Request, result any) error {
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var errResp ErrorResponse
if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error.Message != "" {
return fmt.Errorf("API error: %s (code: %d)", errResp.Error.Message, errResp.Error.Code)
}
return fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
}
if result != nil && len(body) > 0 {
if err := json.Unmarshal(body, result); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
}
return nil
}
// postToMessagesEndpoint POSTs an arbitrary body to the phone number's /messages endpoint.
// Used by sendMessage (typed) and by reaction/read-receipt (different top-level shape).
func (c *Client) postToMessagesEndpoint(ctx context.Context, body any) (string, error) {
path := c.config.PhoneNumberID + "/messages"
jsonData, err := json.Marshal(body)
if err != nil {
return "", fmt.Errorf("failed to marshal message: %w", err)
}
u := fmt.Sprintf("https://graph.facebook.com/%s/%s", c.config.APIVersion, path)
req, err := http.NewRequestWithContext(ctx, "POST", u, bytes.NewBuffer(jsonData))
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.config.AccessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
var errResp ErrorResponse
if err := json.Unmarshal(respBody, &errResp); err == nil {
return "", fmt.Errorf("API error: %s (code: %d)", errResp.Error.Message, errResp.Error.Code)
}
return "", fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(respBody))
}
var sendResp SendMessageResponse
if err := json.Unmarshal(respBody, &sendResp); err != nil {
return "", nil // some endpoints (read receipt) return non-message JSON
}
if len(sendResp.Messages) == 0 {
return "", nil
}
return sendResp.Messages[0].ID, nil
}

View File

@@ -0,0 +1,267 @@
package businessapi
import (
"context"
"errors"
"fmt"
"net/url"
"git.warky.dev/wdevs/whatshooked/pkg/events"
"git.warky.dev/wdevs/whatshooked/pkg/logging"
"go.mau.fi/whatsmeow/types"
)
var errNoBusinessAccount = errors.New("business_account_id is required for this operation")
// SendAudio sends an audio message via Business API.
func (c *Client) SendAudio(ctx context.Context, jid types.JID, audioData []byte, mimeType string) (string, error) {
if ctx == nil {
ctx = context.Background()
}
phoneNumber := jidToPhoneNumber(jid)
mediaID, err := c.uploadMedia(ctx, audioData, mimeType)
if err != nil {
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, "", err))
return "", fmt.Errorf("failed to upload audio: %w", err)
}
reqBody := SendMessageRequest{
MessagingProduct: "whatsapp",
To: phoneNumber,
Type: "audio",
Audio: &MediaObject{ID: mediaID},
}
messageID, err := c.sendMessage(ctx, reqBody)
if err != nil {
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, "", err))
return "", err
}
logging.Debug("Audio sent via Business API", "account_id", c.id, "to", phoneNumber)
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, ""))
return messageID, nil
}
// SendSticker sends a sticker message via Business API.
func (c *Client) SendSticker(ctx context.Context, jid types.JID, stickerData []byte, mimeType string) (string, error) {
if ctx == nil {
ctx = context.Background()
}
phoneNumber := jidToPhoneNumber(jid)
mediaID, err := c.uploadMedia(ctx, stickerData, mimeType)
if err != nil {
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, "", err))
return "", fmt.Errorf("failed to upload sticker: %w", err)
}
reqBody := SendMessageRequest{
MessagingProduct: "whatsapp",
To: phoneNumber,
Type: "sticker",
Sticker: &MediaObject{ID: mediaID},
}
messageID, err := c.sendMessage(ctx, reqBody)
if err != nil {
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, "", err))
return "", err
}
logging.Debug("Sticker sent via Business API", "account_id", c.id, "to", phoneNumber)
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, ""))
return messageID, nil
}
// SendLocation sends a location message via Business API.
func (c *Client) SendLocation(ctx context.Context, jid types.JID, latitude, longitude float64, name, address string) (string, error) {
if ctx == nil {
ctx = context.Background()
}
phoneNumber := jidToPhoneNumber(jid)
reqBody := SendMessageRequest{
MessagingProduct: "whatsapp",
To: phoneNumber,
Type: "location",
Location: &LocationObject{
Latitude: latitude,
Longitude: longitude,
Name: name,
Address: address,
},
}
messageID, err := c.sendMessage(ctx, reqBody)
if err != nil {
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, "", err))
return "", err
}
logging.Debug("Location sent via Business API", "account_id", c.id, "to", phoneNumber)
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, fmt.Sprintf("%.6f,%.6f", latitude, longitude)))
return messageID, nil
}
// SendContacts sends one or more contact cards.
func (c *Client) SendContacts(ctx context.Context, jid types.JID, contacts []SendContactObject) (string, error) {
if ctx == nil {
ctx = context.Background()
}
phoneNumber := jidToPhoneNumber(jid)
reqBody := SendMessageRequest{
MessagingProduct: "whatsapp",
To: phoneNumber,
Type: "contacts",
Contacts: contacts,
}
messageID, err := c.sendMessage(ctx, reqBody)
if err != nil {
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, "", err))
return "", err
}
logging.Debug("Contacts sent via Business API", "account_id", c.id, "to", phoneNumber)
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, ""))
return messageID, nil
}
// SendInteractive sends an interactive message (buttons, list, or flow).
func (c *Client) SendInteractive(ctx context.Context, jid types.JID, interactive *InteractiveObject) (string, error) {
if ctx == nil {
ctx = context.Background()
}
phoneNumber := jidToPhoneNumber(jid)
reqBody := SendMessageRequest{
MessagingProduct: "whatsapp",
To: phoneNumber,
Type: "interactive",
Interactive: interactive,
}
messageID, err := c.sendMessage(ctx, reqBody)
if err != nil {
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, "", err))
return "", err
}
logging.Debug("Interactive message sent via Business API", "account_id", c.id, "to", phoneNumber, "type", interactive.Type)
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, ""))
return messageID, nil
}
// SendTemplateMessage sends a template message.
func (c *Client) SendTemplateMessage(ctx context.Context, jid types.JID, tmpl *TemplateMessageObject) (string, error) {
if ctx == nil {
ctx = context.Background()
}
phoneNumber := jidToPhoneNumber(jid)
reqBody := SendMessageRequest{
MessagingProduct: "whatsapp",
To: phoneNumber,
Type: "template",
Template: tmpl,
}
messageID, err := c.sendMessage(ctx, reqBody)
if err != nil {
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, "", err))
return "", err
}
logging.Debug("Template message sent via Business API", "account_id", c.id, "to", phoneNumber, "template", tmpl.Name)
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, tmpl.Name))
return messageID, nil
}
// SendReaction sends a reaction (emoji) to an existing message.
func (c *Client) SendReaction(ctx context.Context, jid types.JID, messageID string, emoji string) error {
if ctx == nil {
ctx = context.Background()
}
phoneNumber := jidToPhoneNumber(jid)
msg := ReactionMessage{
MessagingProduct: "whatsapp",
MessageType: "reaction",
Reaction: ReactionObject{
MessageID: messageID,
Emoji: emoji,
},
}
if _, err := c.postToMessagesEndpoint(ctx, msg); err != nil {
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, emoji, err))
return err
}
logging.Debug("Reaction sent via Business API", "account_id", c.id, "to", phoneNumber, "emoji", emoji)
return nil
}
// MarkAsRead marks a received message as read.
func (c *Client) MarkAsRead(ctx context.Context, messageID string) error {
if ctx == nil {
ctx = context.Background()
}
msg := ReadReceiptMessage{
MessagingProduct: "whatsapp",
Status: "read",
MessageID: messageID,
}
if _, err := c.postToMessagesEndpoint(ctx, msg); err != nil {
return err
}
logging.Debug("Message marked as read via Business API", "account_id", c.id, "message_id", messageID)
return nil
}
// ListPhoneNumbers returns all phone numbers for the business account.
func (c *Client) ListPhoneNumbers(ctx context.Context) (*PhoneNumberListResponse, error) {
if c.config.BusinessAccountID == "" {
return nil, errNoBusinessAccount
}
params := url.Values{
"fields": {"id,display_phone_number,phone_number,verified_name,code_verification_status,quality_rating,throughput"},
}
var resp PhoneNumberListResponse
if err := c.graphAPIGet(ctx, c.config.BusinessAccountID+"/phone_numbers", params, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// RequestVerificationCode sends a verification code (SMS or VOICE) to the given phone number.
func (c *Client) RequestVerificationCode(ctx context.Context, phoneNumberID string, method string) error {
body := map[string]string{
"verification_method": method,
}
var resp RequestCodeResponse
return c.graphAPIPost(ctx, phoneNumberID+"/request_code", body, &resp)
}
// VerifyCode verifies a phone number with the code received via SMS/VOICE.
func (c *Client) VerifyCode(ctx context.Context, phoneNumberID string, code string) error {
body := VerifyCodeRequest{Code: code}
var resp VerifyCodeResponse
return c.graphAPIPost(ctx, phoneNumberID+"/verify_code", body, &resp)
}
// DeleteMedia deletes a previously uploaded media file.
func (c *Client) DeleteMedia(ctx context.Context, mediaID string) error {
return c.graphAPIDelete(ctx, mediaID, nil)
}

View File

@@ -0,0 +1,50 @@
package businessapi
import (
"context"
"net/url"
)
// ListTemplates returns all message templates for the business account.
// Requires BusinessAccountID in the client config.
func (c *Client) ListTemplates(ctx context.Context) (*TemplateListResponse, error) {
if c.config.BusinessAccountID == "" {
return nil, errNoBusinessAccount
}
params := url.Values{
"fields": {"id,name,status,language,category,created_at,components,rejection_reasons,quality_score"},
}
var resp TemplateListResponse
if err := c.graphAPIGet(ctx, c.config.BusinessAccountID+"/message_templates", params, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// UploadTemplate creates a new message template.
func (c *Client) UploadTemplate(ctx context.Context, tmpl TemplateUploadRequest) (*TemplateUploadResponse, error) {
if c.config.BusinessAccountID == "" {
return nil, errNoBusinessAccount
}
var resp TemplateUploadResponse
if err := c.graphAPIPost(ctx, c.config.BusinessAccountID+"/message_templates", tmpl, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// DeleteTemplate deletes a template by name and language.
func (c *Client) DeleteTemplate(ctx context.Context, name, language string) error {
if c.config.BusinessAccountID == "" {
return errNoBusinessAccount
}
params := url.Values{
"name": {name},
"language": {language},
}
return c.graphAPIDelete(ctx, c.config.BusinessAccountID+"/message_templates", params)
}

View File

@@ -1,15 +1,23 @@
package businessapi
// SendMessageRequest represents a request to send a text message via Business API
import "encoding/json"
// SendMessageRequest represents a request to send a message via Business API
type SendMessageRequest struct {
MessagingProduct string `json:"messaging_product"` // Always "whatsapp"
RecipientType string `json:"recipient_type,omitempty"` // "individual"
To string `json:"to"` // Phone number in E.164 format
Type string `json:"type"` // "text", "image", "video", "document"
Text *TextObject `json:"text,omitempty"`
Image *MediaObject `json:"image,omitempty"`
Video *MediaObject `json:"video,omitempty"`
Document *DocumentObject `json:"document,omitempty"`
MessagingProduct string `json:"messaging_product"` // Always "whatsapp"
RecipientType string `json:"recipient_type,omitempty"` // "individual"
To string `json:"to"` // Phone number in E.164 format
Type string `json:"type"` // "text", "image", "video", "document", "audio", "sticker", "location", "contacts", "interactive", "template"
Text *TextObject `json:"text,omitempty"`
Image *MediaObject `json:"image,omitempty"`
Video *MediaObject `json:"video,omitempty"`
Document *DocumentObject `json:"document,omitempty"`
Audio *MediaObject `json:"audio,omitempty"`
Sticker *MediaObject `json:"sticker,omitempty"`
Location *LocationObject `json:"location,omitempty"`
Contacts []SendContactObject `json:"contacts,omitempty"`
Interactive *InteractiveObject `json:"interactive,omitempty"`
Template *TemplateMessageObject `json:"template,omitempty"`
}
// TextObject represents a text message
@@ -82,10 +90,12 @@ type WebhookEntry struct {
Changes []WebhookChange `json:"changes"`
}
// WebhookChange represents a change notification
// WebhookChange represents a change notification.
// Value is kept as raw JSON because the shape differs per Field type:
// "messages" → WebhookValue, "message_template_status_update" → TemplateStatusValue, etc.
type WebhookChange struct {
Value WebhookValue `json:"value"`
Field string `json:"field"` // "messages"
Value json.RawMessage `json:"value"`
Field string `json:"field"`
}
// WebhookValue contains the actual webhook data
@@ -394,3 +404,386 @@ type BusinessAccountDetails struct {
TimezoneID string `json:"timezone_id"`
MessageTemplateNamespace string `json:"message_template_namespace,omitempty"`
}
// TemplateStatusValue represents the value payload in a message_template_status_update webhook.
// This is a distinct shape from WebhookValue — Meta does not include messaging_product or
// metadata in these notifications.
type TemplateStatusValue struct {
Event string `json:"event"` // e.g. "TEMPLATE_APPROVED", "TEMPLATE_REJECTED"
TemplateName string `json:"template_name"` // name used during upload
TemplateID string `json:"template_id"` // Meta-assigned template ID
Language string `json:"language"` // e.g. "en_US"
Status string `json:"status"` // "APPROVED", "REJECTED", "PENDING"
RejectionReasons []string `json:"rejection_reasons"` // non-empty only when Status == "REJECTED"
}
// ---------------------------------------------------------------------------
// Outgoing message types (sending)
// ---------------------------------------------------------------------------
// LocationObject is used when sending a location message
type LocationObject struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Name string `json:"name,omitempty"`
Address string `json:"address,omitempty"`
}
// SendContactObject is a contact card in an outgoing contacts message
type SendContactObject struct {
Name ContactNameObj `json:"name"`
Addresses []ContactAddressObj `json:"addresses,omitempty"`
Emails []ContactEmailObj `json:"emails,omitempty"`
Phones []ContactPhoneObj `json:"phones,omitempty"`
Org *ContactOrgObj `json:"org,omitempty"`
URLs []ContactURLObj `json:"urls,omitempty"`
}
type ContactNameObj struct {
FormattedName string `json:"formatted_name"`
FirstName string `json:"first_name,omitempty"`
LastName string `json:"last_name,omitempty"`
MiddleName string `json:"middle_name,omitempty"`
Suffix string `json:"suffix,omitempty"`
Prefix string `json:"prefix,omitempty"`
}
type ContactAddressObj struct {
City string `json:"city,omitempty"`
Country string `json:"country,omitempty"`
CountryCode string `json:"country_code,omitempty"`
State string `json:"state,omitempty"`
Street string `json:"street,omitempty"`
Type string `json:"type,omitempty"`
Zip string `json:"zip,omitempty"`
}
type ContactEmailObj struct {
Email string `json:"email,omitempty"`
Type string `json:"type,omitempty"`
}
type ContactPhoneObj struct {
Phone string `json:"phone,omitempty"`
Type string `json:"type,omitempty"`
}
type ContactOrgObj struct {
Company string `json:"company,omitempty"`
Department string `json:"department,omitempty"`
Title string `json:"title,omitempty"`
}
type ContactURLObj struct {
URL string `json:"url,omitempty"`
Type string `json:"type,omitempty"`
}
// InteractiveObject is the payload for interactive messages (buttons, list, flow)
type InteractiveObject struct {
Type string `json:"type"` // "button", "list", "flow"
Header *InteractiveHeader `json:"header,omitempty"`
Body *InteractiveBody `json:"body"`
Footer *InteractiveFooter `json:"footer,omitempty"`
Buttons []InteractiveButton `json:"buttons,omitempty"` // type "button": 1-3 reply buttons
Action *InteractiveAction `json:"action,omitempty"` // type "list" or "flow"
}
type InteractiveHeader struct {
Type string `json:"type"` // "text", "image", "video", "document"
Text string `json:"text,omitempty"`
Image *MediaObject `json:"image,omitempty"`
Video *MediaObject `json:"video,omitempty"`
Document *MediaObject `json:"document,omitempty"`
}
type InteractiveBody struct {
Text string `json:"text"`
}
type InteractiveFooter struct {
Text string `json:"text"`
}
type InteractiveButton struct {
Type string `json:"type"` // "reply"
Reply InteractiveButtonReply `json:"reply"`
}
type InteractiveButtonReply struct {
ID string `json:"id"` // up to 20 chars; returned in webhook reply
Title string `json:"title"` // up to 20 chars; displayed on button
}
// InteractiveAction is shared by list and flow interactive messages
type InteractiveAction struct {
// list fields
Button string `json:"button,omitempty"` // label on the list button
Sections []InteractiveSection `json:"sections,omitempty"` // 1-10 sections
// flow fields
Name string `json:"name,omitempty"` // "flow"
Parameters *FlowActionParams `json:"parameters,omitempty"`
}
type InteractiveSection struct {
Title string `json:"title,omitempty"`
Rows []InteractiveSectionRow `json:"rows"` // 1-10 rows
}
type InteractiveSectionRow struct {
ID string `json:"id"` // up to 20 chars
Title string `json:"title"` // up to 60 chars
Description string `json:"description,omitempty"` // up to 72 chars
}
// FlowActionParams defines parameters when sending an interactive flow message
type FlowActionParams struct {
Type string `json:"type"` // "payload" or "unpublished"
FlowToken string `json:"flow_token"`
Name string `json:"name"` // initial screen name
Data map[string]any `json:"data"` // dynamic key/value pairs for the screen
}
// TemplateMessageObject defines a template message for sending
type TemplateMessageObject struct {
Name string `json:"name"`
Language TemplateLanguage `json:"language"`
Components []TemplateSendComponent `json:"components,omitempty"`
}
type TemplateLanguage struct {
Code string `json:"code"` // e.g. "en_US", "en"
Policy string `json:"policy,omitempty"` // "deterministic"
}
type TemplateSendComponent struct {
Type string `json:"type"` // "header", "body", "buttons"
SubType string `json:"sub_type,omitempty"` // "quick_reply" or "url" (buttons only)
Index string `json:"index,omitempty"` // "0","1","2" (buttons only)
Parameters []TemplateParameter `json:"parameters"`
}
type TemplateParameter struct {
Type string `json:"type"` // "text","currency","date_time","image","video","document","location","payload"
Text string `json:"text,omitempty"`
Currency *CurrencyObject `json:"currency,omitempty"`
DateTime *DateTimeObject `json:"date_time,omitempty"`
Image *MediaObject `json:"image,omitempty"`
Video *MediaObject `json:"video,omitempty"`
Document *MediaObject `json:"document,omitempty"`
Location *LocationObject `json:"location,omitempty"`
Payload string `json:"payload,omitempty"` // quick_reply button payload
}
type CurrencyObject struct {
Currency string `json:"currency"` // ISO 4217
Amount string `json:"amount"`
}
type DateTimeObject struct {
Raw string `json:"raw"` // RFC 3339 datetime string
}
// ReactionMessage is the body sent to react to an existing message.
// Uses "message_type" (not "type") at the top level — distinct from SendMessageRequest.
type ReactionMessage struct {
MessagingProduct string `json:"messaging_product"` // "whatsapp"
MessageType string `json:"message_type"` // "reaction"
Reaction ReactionObject `json:"reaction"`
}
type ReactionObject struct {
MessageID string `json:"message_id"`
Emoji string `json:"emoji"`
}
// ReadReceiptMessage marks a received message as read
type ReadReceiptMessage struct {
MessagingProduct string `json:"messaging_product"` // "whatsapp"
Status string `json:"status"` // "read"
MessageID string `json:"message_id"`
}
// ---------------------------------------------------------------------------
// Template management (CRUD)
// ---------------------------------------------------------------------------
type TemplateListResponse struct {
Data []TemplateInfo `json:"data"`
Paging *PagingInfo `json:"paging,omitempty"`
}
type TemplateInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"` // APPROVED, PENDING, REJECTED
Language string `json:"language"`
Category string `json:"category"` // MARKETING, UTILITY, AUTHENTICATION
CreatedAt string `json:"created_at"`
Components []TemplateComponentDef `json:"components"`
RejectionReasons []string `json:"rejection_reasons,omitempty"`
QualityScore string `json:"quality_score,omitempty"`
}
type TemplateComponentDef struct {
Type string `json:"type"` // HEADER, BODY, FOOTER, BUTTONS
Format string `json:"format,omitempty"` // TEXT, IMAGE, VIDEO, DOCUMENT, LOCATION
Text string `json:"text,omitempty"`
Buttons []TemplateButtonDef `json:"buttons,omitempty"`
Example *TemplateComponentExample `json:"example,omitempty"`
}
type TemplateButtonDef struct {
Type string `json:"type"` // PHONE_NUMBER, URL, QUICK_REPLY, COPY_CODE
Text string `json:"text"`
URL string `json:"url,omitempty"`
PhoneNumber string `json:"phone_number,omitempty"`
Dynamic bool `json:"dynamic,omitempty"`
}
type TemplateComponentExample struct {
HeaderHandle []string `json:"header_handle,omitempty"`
BodyExample [][]string `json:"body_example,omitempty"`
}
type TemplateUploadRequest struct {
Name string `json:"name"`
Language string `json:"language"`
Category string `json:"category"` // MARKETING, UTILITY, AUTHENTICATION
Components []TemplateUploadComponent `json:"components"`
AllowCategoryChange bool `json:"allow_category_change,omitempty"`
}
type TemplateUploadComponent struct {
Type string `json:"type"` // HEADER, BODY, FOOTER, BUTTONS
Format string `json:"format,omitempty"` // TEXT, IMAGE, VIDEO, DOCUMENT, LOCATION (HEADER only)
Text string `json:"text,omitempty"`
Buttons []TemplateUploadButton `json:"buttons,omitempty"`
Example *TemplateUploadExample `json:"example,omitempty"`
}
type TemplateUploadButton struct {
Type string `json:"type"` // PHONE_NUMBER, URL, QUICK_REPLY, COPY_CODE
Text string `json:"text"`
URL string `json:"url,omitempty"`
PhoneNumber string `json:"phone_number,omitempty"`
Example []string `json:"example,omitempty"` // example values for dynamic parts
}
type TemplateUploadExample struct {
HeaderHandle []string `json:"header_handle,omitempty"`
BodyExample [][]string `json:"body_example,omitempty"`
}
type TemplateUploadResponse struct {
Data TemplateUploadData `json:"data"`
}
type TemplateUploadData struct {
ID string `json:"id"`
Status string `json:"status"`
}
// ---------------------------------------------------------------------------
// Flow management (CRUD)
// ---------------------------------------------------------------------------
type FlowListResponse struct {
Data []FlowInfo `json:"data"`
Paging *PagingInfo `json:"paging,omitempty"`
}
type FlowInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"` // DRAFT, PUBLISHED, DEPRECATED, BLOCKED, ERROR
Categories []string `json:"categories"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
EndpointURL string `json:"endpoint_url,omitempty"`
PreviewURL string `json:"preview_url,omitempty"`
SignedPreviewURL string `json:"signed_preview_url,omitempty"`
SignedFlowURL string `json:"signed_flow_url,omitempty"`
}
type FlowCreateRequest struct {
Name string `json:"name"`
Categories []string `json:"categories"` // e.g. ["SIGN_IN"]
EndpointURL string `json:"endpoint_url,omitempty"`
CloneFlowID string `json:"clone_flow_id,omitempty"`
}
type FlowCreateResponse struct {
Data FlowCreateData `json:"data"`
}
type FlowCreateData struct {
ID string `json:"id"`
Status string `json:"status"`
}
type FlowActionResponse struct {
Data FlowActionData `json:"data"`
}
type FlowActionData struct {
Success bool `json:"success"`
Status string `json:"status,omitempty"`
}
// ---------------------------------------------------------------------------
// Phone number management
// ---------------------------------------------------------------------------
type PhoneNumberListResponse struct {
Data []PhoneNumberListItem `json:"data"`
Paging *PagingInfo `json:"paging,omitempty"`
}
type PhoneNumberListItem struct {
ID string `json:"id"`
DisplayPhoneNumber string `json:"display_phone_number"`
PhoneNumber string `json:"phone_number"`
VerifiedName string `json:"verified_name"`
CodeVerificationStatus string `json:"code_verification_status"`
QualityRating string `json:"quality_rating"`
Throughput ThroughputInfo `json:"throughput"`
}
type RequestCodeRequest struct {
CertificateData string `json:"certificate_data,omitempty"`
}
type RequestCodeResponse struct {
Data RequestCodeData `json:"data"`
}
type RequestCodeData struct {
MessageStatus string `json:"message_status"` // "CODE_SENT"
}
type VerifyCodeRequest struct {
Code string `json:"code"`
}
type VerifyCodeResponse struct {
Data VerifyCodeData `json:"data"`
}
type VerifyCodeData struct {
MessageStatus string `json:"message_status"` // "CODE_VERIFIED"
}
// ---------------------------------------------------------------------------
// Shared / pagination
// ---------------------------------------------------------------------------
type PagingInfo struct {
Cursors PagingCursors `json:"cursors"`
Next string `json:"next,omitempty"`
}
type PagingCursors struct {
Before string `json:"before"`
After string `json:"after"`
}