Unicom360.Webhook (Unicom360 v0.1.0)

Copy Markdown View Source

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
end

In 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_error before 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 in text, not in media; payload is set only for location, contacts (%{"contacts" => [...]}), reaction, sticker and unsupported messages; an interactive or button reply has the chosen title in text, payload is nil, and reply_to is the wamid. 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

Parses an email delivery report posted to a send's callback_url.

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.

Types

event()

headers()

@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.

option()

@type option() :: {:tolerance, non_neg_integer()} | {:now, integer()}

:tolerance (ms, default 300_000) and :now (Unix time in ms, default the clock).

Functions

construct_event(raw_body, headers, secret, opts \\ [])

@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.

construct_event!(raw_body, headers, secret, opts \\ [])

@spec construct_event!(iodata(), headers(), String.t() | nil, [option()]) :: event()

Like construct_event/4, but returns the event or raises Unicom360.Error.

parse_email_callback(raw_body)

@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.

parse_email_callback!(raw_body)

@spec parse_email_callback!(iodata()) :: Unicom360.Webhook.EmailDeliveryReport.t()

Like parse_email_callback/1, but returns the report or raises Unicom360.Error.

parse_sms_callback(raw_body)

@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.

parse_sms_callback!(raw_body)

@spec parse_sms_callback!(iodata()) :: Unicom360.Webhook.SmsDeliveryReport.t()

Like parse_sms_callback/1, but returns the report or raises Unicom360.Error.

verify(raw_body, headers, secret, opts \\ [])

@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.

verify!(raw_body, headers, secret, opts \\ [])

@spec verify!(iodata(), headers(), String.t() | nil, [option()]) :: :ok

Like verify/4, but returns :ok or raises Unicom360.Error.