exact_online
An Elixir client for the Exact Online REST API, built on Req.
It handles the parts of the API that are easy to get wrong: OAuth2 with a
rotating refresh token, the OData d envelope, cursor pagination, and the
rate limits Exact Online reports per division.
Installation
def deps do
[{:exact_online, "~> 0.1"}]
end
Quickstart
Register an app in the Exact App Center to get a client id, a client secret and a redirect URI.
credentials = [
client_id: System.fetch_env!("EXACT_CLIENT_ID"),
client_secret: System.fetch_env!("EXACT_CLIENT_SECRET"),
redirect_uri: "https://example.com/oauth/callback",
region: :nl
]
# 1. Send the user to this URL.
Exact.OAuth.authorize_url(credentials)
# 2. Exchange the `code` from the callback and store the token.
{:ok, token} = Exact.OAuth.exchange_code(code, credentials)
:ok = Exact.TokenStore.ETS.put(user_id, token)
# 3. Build a client that refreshes and re-stores the token for you.
client =
Exact.new(
token_store: Exact.TokenStore.ETS,
token_key: user_id,
credentials: credentials
)
# 4. Every other endpoint needs a division.
{:ok, division} = Exact.System.Me.division(client)
client = Exact.Client.put_division(client, division)
{:ok, page} = Exact.CRM.Account.list(client, select: ["ID", "Name"], top: 25)
page.results
There is a runnable walkthrough in
notebooks/exact_online.livemd.
OAuth2
Exact Online access tokens are valid for ten minutes and refresh tokens for thirty days. The refresh token rotates on every refresh: each refresh returns a new one and invalidates the old one, so it has to be persisted or the grant is lost and the user has to authorize again.
That is what Exact.TokenStore is for. Exact.TokenStore.ETS keeps tokens in
memory, which is fine for scripts, Livebook and tests. In an application,
implement the behaviour against your database:
defmodule MyApp.ExactTokens do
@behaviour Exact.TokenStore
@impl true
def fetch(user_id) do
case MyApp.Repo.get(MyApp.ExactToken, user_id) do
nil -> :error
record -> {:ok, MyApp.ExactToken.to_token(record)}
end
end
@impl true
def put(user_id, token) do
MyApp.ExactToken.upsert!(user_id, token)
:ok
end
end
Given a store, the client refreshes the token when it is within a minute of expiring, and once more if the API answers with a 401. Concurrent requests that find an expired token take a lock, so the grant is not spent twice.
You can also skip all of this and pass access_token: directly, in which case
nothing is refreshed.
Regions
Exact Online runs one installation per country. An account created in the Dutch installation is not reachable through the German host.
| Region | Host |
|---|---|
:nl (default) | start.exactonline.nl |
:be | start.exactonline.be |
:de | start.exactonline.de |
:uk | start.exactonline.co.uk |
:fr | start.exactonline.fr |
:es | start.exactonline.es |
:us | start.exactonline.com |
Pass base_url: for a host that is not listed.
Divisions
A division is one administration inside an account, and every endpoint except
Exact.System.Me is scoped to one. Set it once with division: on
Exact.new/1, or rescope an existing client with
Exact.Client.put_division/2. Relative paths get the division prefix; a path
starting with /api is used as-is.
Querying
Query options map onto the OData parameters:
Exact.CRM.Account.list(client,
select: ["ID", "Name", "Email"],
filter: "Status eq " <> Exact.Query.string("C"),
orderby: ["Name asc"],
top: 50,
inlinecount: "allpages"
)
$filter is picky about literals, so use Exact.Query.string/1,
Exact.Query.guid/1 and Exact.Query.datetime/1 rather than interpolating.
Pagination
Exact Online returns 60 records per page (1000 for the bulk and sync
endpoints) and pages with a cursor, not an offset. list/2 gives you one page
and its next cursor; stream/2 follows the cursor lazily:
client
|> Exact.CRM.Account.stream(select: ["ID", "Name"])
|> Stream.map(& &1["Name"])
|> Enum.take(500)
Every page is a request, so narrow the stream with :select and :filter.
stream/2 raises Exact.Error on failure, because a stream has nowhere to put
an error tuple.
Errors
Everything returns {:ok, result} or {:error, %Exact.Error{}}; the bang
variants raise. Match on :reason rather than :status, so transport
failures are covered too:
case Exact.CRM.Account.get(client, id) do
{:ok, account} -> account
{:error, %Exact.Error{reason: :not_found}} -> nil
{:error, %Exact.Error{reason: :rate_limited, retry_after: seconds}} -> {:retry_in, seconds}
{:error, error} -> {:error, error}
end
Transient failures (429 and 5xx on safe methods) are retried up to three times,
honoring Retry-After. Tune it through req_options: [retry: ..., max_retries: ...].
Rate limits
Exact Online enforces a daily and a minutely limit per division and reports the state on every response. The ceilings depend on your agreement, so the client reports what the API says instead of assuming a number.
{:ok, page} = Exact.CRM.Account.list(client, top: 1)
page.rate_limit.minutely_remaining
Exact.Error carries the same struct, so a rate limited caller can back off.
Resources
Exact Online exposes around 600 resources. This library ships modules for the ones most integrations start with:
Exact.System.Me, Exact.System.Division, Exact.CRM.Account,
Exact.CRM.Contact, Exact.Sales.SalesInvoice, Exact.Financial.GLAccount.
For the rest, either call the path directly:
Exact.Client.list(client, "logistics/Items", select: ["ID", "Code"])
Exact.Client.stream(client, "bulk/CRM/Accounts")
Or generate a module, which is what the shipped ones do:
defmodule MyApp.Exact.Item do
@moduledoc "See [Items](https://start.exactonline.nl/docs/HlpRestAPIResourcesDetails.aspx?name=LogisticsItems)."
use Exact.Resource, service: "logistics", resource: "Items"
end
MyApp.Exact.Item.list(client, select: ["ID", "Code", "Description"], top: 25)
That gives you list/2, stream/2, get/3, create/2, update/3 and
delete/2. See Exact.Resource for the options, including read-only resources
and non-GUID primary keys.
Records are plain maps keyed by the field names Exact Online uses. Nothing is renamed, so the reference documentation applies as written.
Not covered
The XML API and webhook signature verification are not wrapped. The bulk and sync endpoints are reachable as ordinary paths but have no dedicated helpers.
Testing your own code
The client takes req_options:, so point it at a
Req.Test stub:
client = Exact.new(division: 123_456, access_token: "test", req_options: [plug: {Req.Test, MyApp.Exact}])
Req.Test.stub(MyApp.Exact, fn conn ->
Req.Test.json(conn, %{"d" => %{"results" => [%{"ID" => "abc", "Name" => "Paradiso"}]}})
end)
Development
mix deps.get
mix test
mix check # format, credo, test, dialyzer
mix docs
License
MIT. See the LICENSE file.