Teya

Test Status Coverage Status Hex Version Hex Docs

Elixir client for the Teya API.

Installation

Add teya to your dependencies in mix.exs:

def deps do
[
{:teya, "~> 1.0.0"}
]
end

Configuration

# config/runtime.exs
config :teya,
client_id: System.fetch_env!("TEYA_CLIENT_ID"),
client_secret: System.fetch_env!("TEYA_CLIENT_SECRET"),
scopes: [
# list only the scopes your application needs; see "Scope reference" below
]

OAuth tokens are fetched automatically and refreshed before expiry. Only request the scopes your application needs.

Several sets of credentials

Online Payments and Payments Gateway use the client from the Teya Developer Portal. POSLink uses a different one: the client that ePOS registration returns, once per store, with the scopes it returns. To use both, or several stores, name each set under :credentials:

# config/runtime.exs
config :teya,
credentials: [
online: [
client_id: System.fetch_env!("TEYA_CLIENT_ID"),
client_secret: System.fetch_env!("TEYA_CLIENT_SECRET"),
scopes: ["checkout/sessions/create", "checkout/sessions/id/get"]
],
poslink: [
client_id: System.fetch_env!("TEYA_EPOS_CLIENT_ID"),
client_secret: System.fetch_env!("TEYA_EPOS_CLIENT_SECRET"),
scopes: String.split(System.fetch_env!("TEYA_EPOS_SCOPES"), ~r/[\s,]+/, trim: true)
],
store_b: [
client_id: System.fetch_env!("TEYA_STORE_B_CLIENT_ID"),
client_secret: System.fetch_env!("TEYA_STORE_B_CLIENT_SECRET"),
scopes: String.split(System.fetch_env!("TEYA_STORE_B_SCOPES"), ~r/[\s,]+/, trim: true)
]
]

Each set gets its own token, and asks only for its own scopes. A call uses:

  1. the set named with the :credentials option, if given;
  2. otherwise :poslink for a POSLink call, or :online for any other, if that set is configured;
  3. otherwise the top-level :client_id, :client_secret and :scopes.

So the common case needs no change to any call, and another store's client is one option away:

Teya.Checkout.create_session(params) # :online
Teya.POSLink.Payment.create(params) # :poslink
Teya.POSLink.Payment.create(params, credentials: :store_b) # store B
Teya.POSLink.Payment.subscribe(id, self(), credentials: :store_b)

credentials: :default picks the top-level credentials, so :default (and nil) cannot name a set. A :credentials name that is not configured raises ArgumentError, and a :credentials setting of the wrong shape stops the application at boot. Sets are read when the application starts, so adding one takes a restart.

The library talks to Teya's production API. To use Teya's staging API instead, with staging credentials, set:

config :teya, environment: :staging
:environment API Token endpoint
:production (default) https://api.teya.com https://id.teya.com/oauth/v2/oauth-token
:staging https://api.teya.xyz https://id.teya.xyz/oauth/v2/oauth-token

To use other URLs, such as a proxy, set :base_url or :token_url. Each wins over the environment's. The environment may be given as text, such as System.get_env("TEYA_ENV", "production").

Each set of credentials reads these settings once, when the application starts it: its token URL and its API host come from the same environment, and every request made with its token goes to its host. So a change while the application runs has no effect on them until the next start, and a token they fetched is never sent to another environment's host. To switch, change the settings and restart the application.

ePOS registration carries a signed-in user's token, which no set holds, and goes to the host the application started with too. So register against the environment the application is running in. If the application could not resolve a host when it started, because it did not know the environment, it has none to keep to, and registration uses the settings as they are when it is called.

These settings are optional:

Setting Default What it does
:token_timeout_ms 15_000 How long a request waits for an access token before it returns an error, or :infinity. A token request still running then carries on, and caches its token for the next request
:sse_stream_timeout_ms 60_000 How long a POSLink stream waits for the next event before it gives up
:sse_max_error_body_bytes 65_536 How much of a failed stream's error body is kept. A JSON error larger than this is cut and can no longer be read, so the error keeps its status but no code and none of the body
:retry_idempotent_posts false Retry a payment, refund or other POST that is safe to repeat when it fails for a reason that may pass. See Retries

Scope reference

The scope names below come from Teya's API specifications. Where a spec leaves a scope out, the notes under each table say so and where the name comes from instead.

Online Payments scopes

Scope Library function
checkout/sessions/create Teya.Checkout.create_session/2
checkout/sessions/id/get Teya.Checkout.get_session/1
payment-links/create Teya.PayByLink.create/2
payment-links/id/get Teya.PayByLink.get/1
payment-links/id/update Teya.PayByLink.update/2
transactions/online/create Teya.Transaction.create/2
transactions/online/id/get Teya.Transaction.get/1
captures/create Teya.Capture.create/3
refunds/create Teya.Refund.create/2
transactions/id/receipts/create Teya.Receipt.create/3
token/delete Teya.Token.delete/3

Payments Gateway scopes

The specification names no scopes for Teya.CardPresent.create/2, Teya.Moto.create/2 or Teya.Reversal.create/2. If the token endpoint answers invalid_scope, or these calls answer 401 or 403, ask Teya which scopes your credentials need.

Dynamic Currency Conversion scopes

Scope Library function
fx/dcc/create Teya.DCC.quote/2
Scope Library function
payment_requests Teya.POSLink.Payment.create/2, Teya.POSLink.Payment.list/1, Teya.POSLink.Payment.cancel/2
payment_requests/id Teya.POSLink.Payment.get/2, Teya.POSLink.Payment.subscribe/2, Teya.POSLink.Payment.receipt_text/2
stores/id/terminals Teya.POSLink.Store.list/1, Teya.POSLink.Store.list_terminals/2
refunds Teya.POSLink.Refund.create/2 (from registration; see below)

These are the scopes ePOS registration returns, for the client it returns. Put that client in the :poslink set (see Several sets of credentials), with the scopes registration gave it and no others: asking for scopes the client was not given can make the token request fail with invalid_scope.

The older default_access also works for payment requests and stores, but Teya plans to remove it.

The refunds scope is not the Online Payments refunds/create: each works only for its own module. Registration describes refunds as "for refund operations", but the refund endpoint itself names no scope.

The specification names no scope for receipt requests (Teya.POSLink.Receipt.create/2, Teya.POSLink.Receipt.subscribe_status/2) or store settings (Teya.POSLink.Store.terminal_configs/3, Teya.POSLink.Store.put_config/4). If these calls answer 401 or 403 with the scopes registration gave you, ask Teya which they need. Teya.POSLink.Epos.register/2 takes a signed-in user's token rather than scopes.

Obtain credentials from the Teya Developer Portal.

Usage

Hosted Checkout

Redirect customers to a Teya-hosted payment page:

params = %{
"amount" => %{"currency" => "GBP", "value" => 1000},
"type" => "SALE",
"success_url" => "https://example.com/success",
"failure_url" => "https://example.com/failure"
}
case Teya.Checkout.create_session(params) do
{:ok, %{"session_url" => url}} ->
# redirect the customer to url
{:error, %Teya.Error{code: code, message: message}} ->
# handle error
end

Poll for the result after the customer returns:

{:ok, session} = Teya.Checkout.get_session(session_id)
session["payment_status"] # "NONE" | "SUCCESS" | "FAILED"
session["session_status"] # "ACTIVE" | "PROCESSING" | "COMPLETED" | "EXPIRED"

Direct Card Processing (Embedded UI)

Process a card payment from your own payment form:

params = %{
"amount" => %{"currency" => "GBP", "value" => 1000},
"type" => "SALE",
"initiator" => "CUSTOMER",
"store_id" => "your-store-uuid",
"payment_method" => %{
"type" => "CARD",
"card" => %{
"number" => "4111111111111111",
"expiry_month" => "12",
"expiry_year" => "2028",
"cvc" => "123"
}
}
}
case Teya.Transaction.create(params) do
{:ok, %{"type" => "ONLINE_TRANSACTION", "online_transaction" => txn}} ->
txn["status"] # "SUCCESS" | "FAILURE" | "PENDING"
{:ok, %{"type" => "REDIRECT_TRANSACTION_RESPONSE"} = resp} ->
# 3DS challenge required — redirect customer to:
resp["redirect_transaction_response"]["redirect_url"]
{:error, %Teya.Error{} = err} ->
# handle error
end

Generate a shareable payment link:

{:ok, %{"payment_link" => url}} =
Teya.PayByLink.create(%{
"amount" => %{"currency" => "GBP", "value" => 5000},
"expires_at" => "2024-12-31T23:59:59Z"
})

Capture a Pre-authorisation

:ok = Teya.Capture.create(transaction_id)

Refund

{:ok, _} = Teya.Refund.create(%{"transaction_id" => transaction_id})

Webhooks

Teya signs every webhook it sends, and you should check the signature before you trust the body. Teya.Webhook.parse/3 takes only a key read by Teya.Webhook.decode_key/1, so read it once, when your application starts. A bad key then stops the application there rather than turning away every webhook:

# in MyApp.Application.start/2
{:ok, key} = Teya.Webhook.decode_key(System.fetch_env!("TEYA_WEBHOOK_KEY"))
:persistent_term.put(:teya_webhook_key, key)

Then check each webhook in its handler:

require Logger
key = :persistent_term.get(:teya_webhook_key)
signature = conn |> Plug.Conn.get_req_header("x-teya-signature") |> List.first()
case Teya.Webhook.parse(conn.assigns[:raw_body], signature, key) do
{:ok, %{"event" => "payment.succeeded.v1", "data" => data}} -> fulfil_order(data)
{:ok, _other_event} -> :ok
{:error, reason} -> Logger.warning("rejected a webhook: #{inspect(reason)}")
end

The signature covers the exact bytes Teya sent, so the body must be kept as received. The Teya.Webhook docs show how to keep it, and cover retries and replayed webhooks.

Card-Present (Direct Terminal Integration)

Process a payment where your software supplies the raw card data from a POS terminal (EMV TLV, encrypted track, PIN block). For Teya-managed terminals accessed through ePOS middleware, see POSLink instead.

params = %{
"type" => "SALE",
"entry_mode" => "CONTACT_EMV",
"amounts" => %{"amount" => 1000, "currency" => "GBP"},
"emv_data" => "9F2608AABBCCDD112233",
"track_data" => %{
"encryption_key_id" => "key-1",
"encrypted_track" => "...",
"encryption_ksn" => "ksn-1"
},
"transacted_at" => DateTime.utc_now() |> DateTime.to_iso8601()
}
{:ok, response} = Teya.CardPresent.create(params)
response["status"] # "SUCCESS" | "FAILURE" | "PENDING"

MOTO (Mail and Telephone Orders)

For a card taken over the phone or by post and entered in a virtual terminal. The card details are sent encrypted with a key Teya provides; handling them at all puts your software within PCI DSS scope.

{:ok, %{"status" => "SUCCESS"}} =
Teya.Moto.create(%{
"type" => "SALE",
"amounts" => %{"amount" => 2500, "currency" => "GBP"},
"card_details" => %{
"encrypted_card_data" => ciphertext,
"encryption_key_id" => key_id,
"encryption_ksn" => ksn
},
"transacted_at" => "2026-09-25T10:30:00Z"
})

Reversal

Void a transaction before it settles with the card network. Use a refund (Teya.Refund) for transactions that have already settled.

# Reverse by transaction ID
{:ok, response} = Teya.Reversal.create(%{
"reversal_reason" => "CARD_REVERSAL",
"transaction_id" => transaction_id
})
# Or by the idempotency key used when creating the original transaction
{:ok, response} = Teya.Reversal.create(%{
"reversal_reason" => "COMMUNICATION_REVERSAL",
"idempotency_key" => original_idempotency_key
})
response["status"] # "SUCCESS" | "FAILURE" | "PENDING" | "ACKNOWLEDGED"

Dynamic Currency Conversion (DCC)

Before a card-present transaction, check whether the cardholder's card is eligible for DCC and get an offer at the current rate. Teya keeps the quote behind the offer, and the payment refers to it by quote_id. This uses the :online set of credentials if there is one, else the top-level ones, and needs the fx/dcc/create scope among that set's :scopes. Add that scope only once Teya has granted it to your client: asking for a scope the client lacks can fail the token request with invalid_scope, which stops every call that uses those credentials, not only DCC.

require Logger
case Teya.DCC.quote(%{
"store_id" => store_id,
"card_first9" => String.slice(card_number, 0, 9),
"base_amount" => 1000,
"base_currency" => "GBP"
}) do
{:ok, offer} ->
# Offer the cardholder: pay offer["cardholder_amount"] offer["cardholder_currency"]
# If accepted, include the quote in the card-present transaction:
dcc_params = %{
"quote_id" => offer["quote_id"],
"cardholder_amount" => %{
"amount" => offer["cardholder_amount"],
"currency" => offer["cardholder_currency"]
}
}
Teya.CardPresent.create(Map.put(card_present_params, "dcc", dcc_params))
{:error, reason} ->
# No offer: proceed without DCC. Log the reason, so a setup problem, such
# as a 403 for a missing scope, does not go unseen.
Logger.warning("no DCC offer: #{inspect(reason)}")
Teya.CardPresent.create(card_present_params)
end

POSLink integrates ePOS software with physical payment terminals. Discover available stores and terminals, then create payment requests and stream their status in real time.

Register an ePOS application

Registering once per store turns a signed-in user's token into credentials for the library. It is a setup step: store the client_id, client_secret and scopes that come back as a set under :credentials, such as :poslink (see Several sets of credentials), then restart the application, which reads them only when it starts. Registering needs no credentials of its own.

{:ok, %{"client_id" => id, "client_secret" => secret, "scopes" => scopes}} =
Teya.POSLink.Epos.register(
%{"store_id" => store_id, "epos_external_id" => "till-1"},
user_token: user_jwt
)

The client secret is a credential: store it as you would a password.

Discover stores and terminals

{:ok, %{"stores" => stores}} = Teya.POSLink.Store.list()
store_id = hd(stores)["store_id"]
{:ok, %{"terminals" => terminals}} = Teya.POSLink.Store.list_terminals(store_id)
terminal_id = hd(terminals)["terminal_id"]

A store's settings apply to all its terminals. Read them for one terminal, or change one for the whole store:

{:ok, %{"configs" => configs}} = Teya.POSLink.Store.terminal_configs(store_id, terminal_id)
{:ok, _} = Teya.POSLink.Store.put_config(store_id, "PAT_ENABLED", "true")

Take a card-present payment

Create a payment request and subscribe to real-time status updates via SSE. Events arrive as messages to the calling process:

params = %{
"store_id" => store_id,
"terminal_id" => terminal_id,
"requested_amount" => %{"amount" => 1000, "currency" => "GBP"},
"transaction_type" => "SALE",
"merchant_reference" => "order-1234"
}
{:ok, %{"payment_request_id" => id}} = Teya.POSLink.Payment.create(params)
{:ok, %Task{ref: ref}} = Teya.POSLink.Payment.subscribe(id, self())
receive do
{:poslink_payment, ^ref, ^id, "full", %{"status" => "SUCCESSFUL"} = data} ->
# payment complete — data contains full transaction metadata
{:poslink_payment, ^ref, ^id, _type, %{"status" => "FAILED"}} ->
# card declined or terminal error
{:poslink_payment, ^ref, ^id, _type, %{"status" => status}} when status in ["NEW", "IN_PROGRESS"] ->
# intermediate state — keep waiting
{:poslink_payment_error, ^ref, ^id, reason} ->
# connection or auth failure
end

subscribe/2 returns immediately; the task runs under Teya.TaskSupervisor and sends messages until the server closes the stream. The second argument is the recipient pid and defaults to self().

Every message carries the task's ref, so two subscriptions to the same payment, such as a second one opened after a drop, can be told apart: pin ^ref when you match. If the recipient is not the caller, pass it the ref.

Task lifecycle: The spawned task is not linked to the caller and is not restarted by the supervisor. If the SSE stream drops mid-payment (network error, server restart), the task sends {:poslink_payment_error, ref, id, reason} and exits — there is no automatic reconnection. To recover, call Teya.POSLink.Payment.get/2 to fetch the current status, or call subscribe/2 again with the same payment_request_id.

Cancel a payment

{:ok, _} = Teya.POSLink.Payment.cancel(payment_request_id)
{:ok, _} = Teya.POSLink.Refund.create(%{
"transaction_id" => gateway_payment_id,
"amount" => 1500
})

transaction_id must be the gateway_payment_id of the original payment. It arrives on the payment's status stream when the payment completes. Do not send the payment's own transaction_id — it is a different identifier and the refund fails with 404 TRANSACTION_NOT_FOUND.

Print a receipt

Submit a receipt print job and stream its printer status:

{:ok, %{"receipt_id" => receipt_id}} =
Teya.POSLink.Receipt.create(%{
"store_id" => store_id,
"terminal_id" => terminal_id,
"content" => %{"type" => "JSON", "data" => %{"total" => "£10.00"}}
})
{:ok, %Task{ref: ref}} = Teya.POSLink.Receipt.subscribe_status(receipt_id, self())
receive do
{:poslink_receipt, ^ref, ^receipt_id, _type, %{"status" => "PRINTED"}} -> :ok
{:poslink_receipt, ^ref, ^receipt_id, _type, %{"status" => "FAILED"}} -> handle_failure()
{:poslink_receipt_error, ^ref, ^receipt_id, reason} -> handle_error(reason)
end

Receipt text

A successful payment or refund has a plain-text receipt, ready to print or send:

{:ok, %{"receipt_text" => text}} = Teya.POSLink.Payment.receipt_text(payment_request_id)

Idempotency Keys

POST and PATCH requests automatically include a random Idempotency-Key header. Supply your own to safely retry a request:

Teya.Checkout.create_session(params, idempotency_key: order_id)

DCC offers (Teya.DCC.quote/2) are the exception: Teya documents no Idempotency-Key for them, so none is sent, and a repeated call creates a new quote.

Retries

GET requests are retried by default when they fail for a reason that may pass: a 408, 429, 500, 502, 503 or 504, a connection that timed out, was refused or was closed, or an HTTP/2 request the server did not handle. POST requests are not, since repeating one could charge a card twice.

Some endpoints make repeating safe: sent again with the same Idempotency-Key, they do not act a second time. To retry those too, set:

config :teya, retry_idempotent_posts: true

That covers only POSTs whose Teya spec documents the key:

They are retried for the same reasons as GET requests. Every retry sends the same key as the first attempt, your own if you gave one, and the current access token. Other writes, such as receipts and reversals, are sent once.

A retry does not always bring back the first answer. If the first attempt reached Teya but its response was lost, the retry can fail instead, for example with a 409 conflict because the key was already used. So an error from a call that was retried does not prove that nothing happened: the payment may have gone through. Sending the request again with the same key stays safe. Before you use a new key, which could charge the card twice, find out what happened. How depends on the endpoint:

So with retries on, pass your own key, such as your order id. A key the library makes up is never given back to you, so after an error you could neither send it again nor use it to reverse the payment:

Teya.Moto.create(params, idempotency_key: order_id)

Retries follow Req's defaults: up to 3 more attempts, about 1, 2 and 4 seconds apart. A 429 or 503 with a Retry-After header is retried after the wait it asks for, if that is 10 seconds or less. A longer wait, or one that cannot be read, is not waited out: the error comes back at once, since your process would sit blocked for it. Each attempt can take up to the 30 second receive timeout, so a call can take up to about two and a half minutes before it gives up. Change this with :max_retries and :retry_delay in :req_options.

A :retry set in :req_options wins over all of this, for every request. retry: :transient there retries every write, including those that are not safe to repeat.

Error Handling

All functions that call Teya return {:ok, body} or {:error, %Teya.Error{}}:

case Teya.Checkout.create_session(params) do
{:ok, response} -> response
{:error, %Teya.Error{code: "TOO_MANY_REQUESTS"}} -> {:error, :rate_limited}
{:error, %Teya.Error{code: code}} when code in ["UNAUTHORISED", "UNAUTHORIZED"] -> {:error, :unauthorized}
{:error, %Teya.Error{reason: {:no_token, _cause}}} -> {:error, :not_sent}
{:error, %Teya.Error{status: nil, reason: reason}} -> {:error, {:no_answer, reason}}
{:error, %Teya.Error{status: status}} -> {:error, status}
end

When the API says which request fields it rejected, they are kept in invalid_parameters:

{:error, %Teya.Error{code: "BAD_REQUEST", invalid_parameters: params}} =
Teya.Checkout.create_session(bad_params)
# [%{"name" => "amount", "reason" => "must be positive"}]

Token endpoint failures use the OAuth 2.0 error format, so code holds values such as "invalid_client" and "invalid_scope".

A request with no usable answer returns a %Teya.Error{} too, with the cause in reason:

A reply whose JSON will not decode keeps its status but none of its body, since it could hold a card number or a credential. A 2xx status there means Teya acted on the request, unless reason is {:no_token, _}: then the status is the token endpoint's.

Ids that go into the request path, such as a session or payment request id, are URL-encoded, so a / or ? in one cannot reach a different endpoint. Such an id that is nil, empty, ".", "..", or anything but text or an integer raises ArgumentError before any request is sent: that is a mistake in the calling code, not something the API said. The subscribe functions raise it too, in your process, not in the task they start. Ids sent as query parameters, such as the store_id for Teya.Token.delete/3, are encoded as query values and not checked this way.

User agent

Every request sends User-Agent: teya-elixir/<version>, which Teya recommends so they can identify your integration. To send your own, use Req's :user_agent option, or a user-agent header:

config :teya, req_options: [user_agent: "acme-shop/1.0"]

Other headers and options you set there are used too, with four exceptions the library always sets itself. API calls always send the library's own bearer token, so an :auth option there is ignored. Replies are decoded by the library, so options such as :decoders, :decode_json and :raw are ignored, and JSON keys are always strings. API calls carry their own Idempotency-Key, since one key shared by every request would make each POST look like a retry of the first. Token requests are always sent as a form, whatever content type is set.

Token requests and SSE streams use :auth_req_options and :sse_req_options when you set them, and :req_options when you do not. Every other call, DCC offers included, uses :req_options.

Troubleshooting

Rate limiting (TOO_MANY_REQUESTS)

Teya returns HTTP 429 when you exceed the rate limit. With :retry_idempotent_posts on, the POSTs it covers have already been retried by the time you see the error, so do not retry them again straight away. Otherwise, back off and retry using the same idempotency key to avoid duplicate operations:

case Teya.POSLink.Payment.create(params, idempotency_key: ref) do
{:error, %Teya.Error{code: "TOO_MANY_REQUESTS"}} ->
Process.sleep(1_000)
Teya.POSLink.Payment.create(params, idempotency_key: ref)
result ->
result
end

3DS redirect flow

When Teya.Transaction.create/2 returns {:ok, %{"type" => "REDIRECT_TRANSACTION_RESPONSE"}}, the cardholder must complete a 3DS challenge before the payment is authorised. Redirect them to resp["redirect_transaction_response"]["redirect_url"] and poll Teya.Transaction.get/1 after they return to your success_url / failure_url.

SSE stream disconnects mid-payment

If a {:poslink_payment_error, ref, id, _reason} message arrives before a terminal status ("SUCCESSFUL", "FAILED", "CANCELLED"), the SSE connection dropped. The payment may or may not have completed on the terminal. Check the current state with Teya.POSLink.Payment.get/2, then re-subscribe with Teya.POSLink.Payment.subscribe/2 if still in progress.

Auth token refresh failures

If the token endpoint is unreachable, the auth process retries after 1 second, doubling the wait each time up to 1 minute. The cached token (if any) stays in use until shortly before it expires. After that, API calls return {:error, %Teya.Error{}} until a token request succeeds again, with no restart needed.

Development

Requirements

Setup

Install dependencies and git hooks:

./bin/setup
mix setup

./bin/setup installs actionlint, check-jsonschema, and Lefthook via Homebrew, then activates the pre-commit hooks.

This installs pre-commit hooks (mix format, mix compile) and pre-push hooks (mix credo, mix test).

Common commands

mix deps.get # install dependencies
mix test # run tests
mix format # format code
mix docs # generate documentation

Tests use Req.Test to stub HTTP — no network access or real credentials required.