exact_online

Hex.pmDocs

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.

RegionHost
:nl (default)start.exactonline.nl
:bestart.exactonline.be
:destart.exactonline.de
:ukstart.exactonline.co.uk
:frstart.exactonline.fr
:esstart.exactonline.es
:usstart.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.