# `Unicom360.Webhook`
[🔗](https://github.com/TechRode-Solution-Limited/unicom360-elixir/blob/v0.1.0/lib/unicom360/webhook.ex#L1)

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.

# `event`

```elixir
@type event() ::
  Unicom360.Webhook.MessageStatusEvent.t()
  | Unicom360.Webhook.MessageReceivedEvent.t()
  | Unicom360.Webhook.TestEvent.t()
  | Unicom360.Webhook.UnknownEvent.t()
```

# `headers`

```elixir
@type headers() ::
  [{String.t(), String.t()}]
  | %{optional(String.t()) =&gt; 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`

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

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

# `construct_event`

```elixir
@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!`

```elixir
@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`

```elixir
@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!`

```elixir
@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`

```elixir
@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!`

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

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

# `verify`

```elixir
@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!`

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

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
