Verify and parse Unicom360 webhooks, and parse the unsigned SMS and email delivery reports.
WhatsApp events (message.status, message.received, webhook.test) are signed. Verify and
parse a delivery with construct_event/4, passing the raw body, the request headers and
your webhook secret (the 43-character string from the portal, used as it is: never
base64-decode it):
case Unicom360.Webhook.construct_event(raw_body, conn.req_headers, secret) do
{:ok, %Unicom360.Webhook.MessageStatusEvent{data: data}} -> data.status
{:ok, %Unicom360.Webhook.MessageReceivedEvent{data: message}} -> message.text
{:ok, %Unicom360.Webhook.TestEvent{}} -> :ok
{:ok, %Unicom360.Webhook.UnknownEvent{event: event}} -> event
{:error, %Unicom360.Error{type: :signature_verification_error, reason: reason}} -> reason
endIn Phoenix or Plug, Unicom360.Webhook.Plug does all of this, and
Unicom360.Webhook.BodyReader keeps the raw body that Plug.Parsers would otherwise consume.
Good to know:
- Deliveries are retried on any non-2xx (after 30 s, 2 min, 10 min, 30 min, 1 h, 3 h, 6 h
and 12 h). Answer 2xx quickly and drop repeats by the event's
delivery_id. - A secret that is
nil, empty or only whitespace (an unset environment variable, say) is a:configuration_errorbefore any other check, because anyone can forge a signature with an empty key. That is a fault in your configuration, not a failed verification: answer 500, not 401. Otherwise the secret is used exactly as given, never trimmed. - In
message.received: a media caption arrives intext, not inmedia;payloadis set only for location, contacts (%{"contacts" => [...]}), reaction, sticker and unsupported messages; an interactive or button reply has the chosen title intext,payloadisnil, andreply_tois thewamid.id of your interactive message (the button or row id is not passed on). - SMS and email delivery reports (
parse_sms_callback/1,parse_email_callback/1) are unsigned and never retried: anyone can post one, so treat them as hints.
Times are in milliseconds, as everywhere in this SDK: :tolerance defaults to 300_000
(five minutes) and :now is Unix time in milliseconds.
Summary
Types
Request headers: a list of {name, value} pairs (as in conn.req_headers) or a map from name
to a value or a list of values. Names are matched without regard to case; when a header has
several values, the first is used.
:tolerance (ms, default 300_000) and :now (Unix time in ms, default the clock).
Functions
Verifies a delivery (see verify/4), then parses it into an event:
Unicom360.Webhook.MessageStatusEvent, Unicom360.Webhook.MessageReceivedEvent,
Unicom360.Webhook.TestEvent, or Unicom360.Webhook.UnknownEvent for an event this version
does not know (never an error).
Like construct_event/4, but returns the event or raises Unicom360.Error.
Parses an email delivery report posted to a send's callback_url.
Like parse_email_callback/1, but returns the report or raises Unicom360.Error.
Parses an SMS delivery report posted to a send's callback_url.
Like parse_sms_callback/1, but returns the report or raises Unicom360.Error.
Verifies a webhook delivery's signature.
Like verify/4, but returns :ok or raises Unicom360.Error.
Types
@type headers() :: [{String.t(), String.t()}] | %{optional(String.t()) => String.t() | [String.t()]}
Request headers: a list of {name, value} pairs (as in conn.req_headers) or a map from name
to a value or a list of values. Names are matched without regard to case; when a header has
several values, the first is used.
@type option() :: {:tolerance, non_neg_integer()} | {:now, integer()}
:tolerance (ms, default 300_000) and :now (Unix time in ms, default the clock).
Functions
@spec construct_event(iodata(), headers(), String.t() | nil, [option()]) :: {:ok, event()} | {:error, Unicom360.Error.t()}
Verifies a delivery (see verify/4), then parses it into an event:
Unicom360.Webhook.MessageStatusEvent, Unicom360.Webhook.MessageReceivedEvent,
Unicom360.Webhook.TestEvent, or Unicom360.Webhook.UnknownEvent for an event this version
does not know (never an error).
The event's delivery_id is the X-Unicom360-Delivery header: drop repeats by it. A verified
body that is not a JSON object with a string event is an error of type :unicom360_error. A
secret that is nil, empty or only whitespace is a :configuration_error.
Like construct_event/4, but returns the event or raises Unicom360.Error.
@spec parse_email_callback(iodata()) :: {:ok, Unicom360.Webhook.EmailDeliveryReport.t()} | {:error, Unicom360.Error.t()}
Parses an email delivery report posted to a send's callback_url.
Unsigned and never retried: treat it as a hint. Returns
{:ok, %Unicom360.Webhook.EmailDeliveryReport{}} (message_id as a string, though the wire
sends a number; status: "SENT", "DELIVERED", "FAILED" or another value;
email_address; message), or an error of type :unicom360_error for a body that is not a
JSON object.
@spec parse_email_callback!(iodata()) :: Unicom360.Webhook.EmailDeliveryReport.t()
Like parse_email_callback/1, but returns the report or raises Unicom360.Error.
@spec parse_sms_callback(iodata()) :: {:ok, Unicom360.Webhook.SmsDeliveryReport.t()} | {:error, Unicom360.Error.t()}
Parses an SMS delivery report posted to a send's callback_url.
Unsigned and never retried: anyone can post one, so treat it as a hint. Returns
{:ok, %Unicom360.Webhook.SmsDeliveryReport{}} (message_id, status: "SENT",
"DELIVERED", "FAILED", "UNKNOWN_ERROR" or another value, mobile_number), or an error of
type :unicom360_error for a body that is not a JSON object.
@spec parse_sms_callback!(iodata()) :: Unicom360.Webhook.SmsDeliveryReport.t()
Like parse_sms_callback/1, but returns the report or raises Unicom360.Error.
@spec verify(iodata(), headers(), String.t() | nil, [option()]) :: :ok | {:error, Unicom360.Error.t()}
Verifies a webhook delivery's signature.
Returns :ok, or {:error, %Unicom360.Error{type: :signature_verification_error}} whose
reason is the first check that failed: :missing_signature, :missing_timestamp,
:invalid_timestamp, :malformed_signature, :timestamp_out_of_tolerance (more than
:tolerance from :now, in either direction) or :signature_mismatch. The signature is
compared in constant time.
A secret that is nil, empty or only whitespace returns
{:error, %Unicom360.Error{type: :configuration_error}}, with no reason, before any other
check: anyone can forge a signature with an empty key.
raw_body must be the exact bytes received, before any JSON parsing.
Like verify/4, but returns :ok or raises Unicom360.Error.