An Elixir client for the LINE platform — Messaging API today, LIFF / LINE Login planned.
A runnable example app lives at ex_line_demo.
- Credentials are values, never global. Build an
ExLine.Clientand pass it per call. Multi-channel / multi-tenant apps are first-class — there is no global registry to fight. - HTTP is swappable. Requests go through the
ExLine.Client.Adapterbehaviour (default:ExLine.Client.Req), so you can mock the network in tests. - Webhooks verify and route.
ExLine.Webhook.Signature/ExLine.Webhook.Plugverify thex-line-signature;ExLine.EventRouterdispatches events to handlers.
def deps do
[
{:ex_line, "~> 0.1.0"}
]
end:plug is an optional dependency, only needed if you use ExLine.Webhook.Plug /
ExLine.Webhook.BodyReader.
You need credentials from the LINE Developers Console:
| Credential | Where it's used | From |
|---|---|---|
| Channel access token | sending messages (ExLine.Client) |
Messaging API channel |
| Channel secret | webhook signature (ExLine.Webhook) |
Messaging API channel |
ExLine never holds global credential state — you pass what each call needs. There are three ways to supply them, pick per use case:
1. Per-call value (default; multi-channel / multi-tenant friendly). Build a client from wherever you store the token (DB row, etc.) and pass it in:
client = ExLine.Client.new(access_token: channel.access_token)
ExLine.Api.Messaging.push(client, user_id, message)2. From application config (single-channel convenience).
# config/runtime.exs
config :ex_line,
access_token: System.fetch_env!("LINE_CHANNEL_ACCESS_TOKEN"),
channel_id: System.get_env("LINE_CHANNEL_ID")client = ExLine.Client.from_env()3. Webhook secret via a resolver (kept separate from the client). The channel
secret belongs to a different trust boundary, so it is passed directly — as a
static value, or a fn conn -> secret end resolver that picks the right channel
at request time (see Receiving webhooks):
plug ExLine.Webhook.Plug, secret: System.fetch_env!("LINE_CHANNEL_SECRET")
# or, multi-channel:
plug ExLine.Webhook.Plug, secret: fn conn -> MyApp.secret_for(conn) endNever commit tokens or secrets — load them from the environment.
client = ExLine.Client.new(access_token: "CHANNEL_ACCESS_TOKEN")
# push
ExLine.Api.Messaging.push(client, "U123...", ExLine.Message.text("hello"))
# reply (using a webhook replyToken)
ExLine.Api.Messaging.reply(client, reply_token, [
ExLine.Message.text("hi"),
ExLine.Message.Template.buttons("Pick one", [
ExLine.Message.Action.message("A", "a"),
ExLine.Message.Action.postback("B", "action=b")
])
])Push supports idempotent retries via X-Line-Retry-Key:
ExLine.Api.Messaging.push(client, "U123...", msg, retry_key: "a-uuid")Errors come back as {:error, %ExLine.Error{kind: kind}} where kind is one of
:transient, :quota_exceeded, :permanent, or :network (see
ExLine.Error.retryable?/1).
Verify the signature (works with or without Plug):
ExLine.Webhook.Signature.valid?(raw_body, signature, channel_secret)With Plug, preserve the raw body in your parser, then verify in the pipeline. The
:secret option takes a static binary or a fn conn -> secret end resolver so you
can pick the right channel per request:
plug Plug.Parsers,
parsers: [:json],
body_reader: {ExLine.Webhook.BodyReader, :read_body, []},
json_decoder: Jason
plug ExLine.Webhook.Plug, secret: &MyApp.line_secret/1This is the framework-agnostic form (one Plug pipeline owns both the parser and
the verification). In Phoenix the parser lives in your Endpoint, not here — see
Wiring it together (Phoenix).
defmodule MyApp.LineRouter do
use ExLine.EventRouter
text "hi", MyApp.Handler, :hi
default MyApp.Handler, :fallback # required catch-all (also catches unknown events)
# other matchers: message :image, postback "buy", follow, unfollow, join, ...
# before_action runs before each match; here it stashes a client for handlers to use.
@impl true
def before_action(event, assigns), do: {event, Map.put(assigns, :client, MyApp.client())}
end
defmodule MyApp.Handler do
use ExLine.EventHandler # imports ExLine.Message, so `text/1` is in scope
# someone sends "hi" -> reply "hello"
@impl true
def handle_event(:hi, %{"replyToken" => token}, %{client: client}) do
ExLine.Api.Messaging.reply(client, token, text("hello"))
:ok
end
def handle_event(:fallback, _event, _assigns), do: :ok
endThe SDK gives you the verify plug, the parser (ExLine.Webhook.parse/1), and the
router DSL; the controller is yours. The flow is: verify → parse → route →
return 200. parse/1 turns the request body into a list of ExLine.Webhook
event structs, and you hand each one to your router's call/2:
Signature verification has to use the exact bytes LINE sent. You can't re-encode
the parsed payload instead — JSON → map → JSON can reorder keys and change
whitespace, so it would no longer match the signature. But Phoenix's Plug.Parsers
(in your Endpoint) both parses and consumes the body, so the original bytes are
gone by the time your controller runs.
ExLine.Webhook.BodyReader solves this: it keeps an untouched copy of the body in
conn.assigns[:raw_body] while the parser runs. Add it as the body_reader on the
Plug.Parsers your Endpoint already defines — don't add a second Plug.Parsers
in the router (by the time the router runs, the body is already parsed):
# lib/my_app_web/endpoint.ex — the Plug.Parsers Phoenix generated, with body_reader added
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
body_reader: {ExLine.Webhook.BodyReader, :read_body, []},
json_decoder: Phoenix.json_library()The router pipeline then only verifies the signature; the parsing already happened in the Endpoint:
# config/runtime.exs
config :my_app, :line_channel_secret, System.fetch_env!("LINE_CHANNEL_SECRET")# lib/my_app_web/router.ex
pipeline :line_webhook do
# Verifies x-line-signature against the cached raw body (401 on mismatch).
# Wrap the secret in a fn so it's read at request time — router plug options are
# evaluated at compile time, so don't read config/env directly here.
plug ExLine.Webhook.Plug,
secret: fn _conn -> Application.fetch_env!(:my_app, :line_channel_secret) end
end
scope "/line", MyAppWeb do
pipe_through :line_webhook
post "/webhook", WebhookController, :handle
end
BodyReadercaches the raw body (a cheap prepend intoconn.assigns[:raw_body]) for every request, since the Endpoint's parser is global — the same approach Stripe-style webhook verification uses in Phoenix. If you'd rather not cache globally, run a barePlugpipeline scoped to the webhook path with its ownPlug.Parsers+ExLine.Webhook.Pluginstead (the framework-agnostic form shown under Receiving webhooks).
# lib/my_app_web/controllers/webhook_controller.ex
defmodule MyAppWeb.WebhookController do
use MyAppWeb, :controller
def handle(conn, params) do
params
|> ExLine.Webhook.parse()
|> Enum.each(&MyApp.LineRouter.call(&1, %{}))
# Always 200 so LINE doesn't retry; the plug already rejected bad signatures.
send_resp(conn, 200, "")
end
endLINE expects a prompt 200 and retries on timeout, so keep the request fast.
For handlers that do slow work (network calls, DB writes), process the events in a
supervised task and return 200 immediately — this also isolates a failing event
from the rest of the batch:
def handle(conn, params) do
events = ExLine.Webhook.parse(params)
Task.Supervisor.start_child(MyApp.TaskSupervisor, fn ->
Enum.each(events, fn event ->
try do
MyApp.LineRouter.call(event, %{})
rescue
e -> Logger.error("LINE event failed: #{Exception.message(e)}")
end
end)
end)
send_resp(conn, 200, "")
endMock the adapter to assert outbound requests without hitting the network:
# test_helper.exs
Mox.defmock(MyApp.LineAdapterMock, for: ExLine.Client.Adapter)
# in a test
client = ExLine.Client.new(access_token: "tok", adapter: MyApp.LineAdapterMock)
Mox.expect(MyApp.LineAdapterMock, :request, fn req ->
assert req.url == "https://api.line.me/v2/bot/message/push"
{:ok, %{status: 200, body: %{}}}
end)
ExLine.Api.Messaging.push(client, "U1", ExLine.Message.text("hi"))Early. Implemented: client + adapter, message builders (text / sticker / buttons /
confirm + actions), Messaging.reply / push, webhook signature verification +
Plug, and the event routing DSL. Broader Messaging coverage (multicast / broadcast /
rich menu / content) and LIFF support are planned — see notes/.