Current section
Files
Jump to
Current section
Files
README.md
# Teya
[](https://github.com/sgerrand/ex_teya/actions/workflows/ci.yml)
[](https://coveralls.io/github/sgerrand/ex_teya?branch=main)
[](https://hex.pm/packages/teya)
[](https://hexdocs.pm/teya/)
Elixir client for the [Teya API](https://docs.teya.com/apis/overview).
## Installation
Add `teya` to your dependencies in `mix.exs`:
<!-- x-release-please-start-version -->
```elixir
def deps do
[
{:teya, "~> 1.0.0"}
]
end
```
<!-- x-release-please-end -->
## Configuration
```elixir
# 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](#register-an-epos-application) returns, once per store,
with the scopes it returns. To use both, or several stores, name each set
under `:credentials`:
```elixir
# 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;
1. otherwise `:poslink` for a POSLink call, or `:online` for any other, if
that set is configured;
1. 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:
```elixir
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:
```elixir
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](#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` |
#### POSLink scopes
| 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](#register-an-epos-application)
returns, for the client it returns. Put that client in the `:poslink` set
(see [Several sets of credentials](#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](https://docs.teya.com/apis/developer-portal/introduction).
## Usage
### Hosted Checkout
Redirect customers to a Teya-hosted payment page:
```elixir
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:
```elixir
{: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:
```elixir
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
```
### Pay By Link
Generate a shareable payment link:
```elixir
{:ok, %{"payment_link" => url}} =
Teya.PayByLink.create(%{
"amount" => %{"currency" => "GBP", "value" => 5000},
"expires_at" => "2024-12-31T23:59:59Z"
})
```
### Capture a Pre-authorisation
```elixir
:ok = Teya.Capture.create(transaction_id)
```
### Refund
```elixir
{: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:
```elixir
# 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:
```elixir
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](#poslink-card-present-terminals)
instead.
```elixir
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.
```elixir
{: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.
```elixir
# 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.
```elixir
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 (Card-Present Terminals)
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](#several-sets-of-credentials)), then
restart the application, which reads them only when it starts. Registering
needs no credentials of its own.
```elixir
{: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
```elixir
{: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:
```elixir
{: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:
```elixir
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
```elixir
{:ok, _} = Teya.POSLink.Payment.cancel(payment_request_id)
```
#### POSLink refunds
```elixir
{: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:
```elixir
{: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:
```elixir
{: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:
```elixir
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:
```elixir
config :teya, retry_idempotent_posts: true
```
That covers only POSTs whose Teya spec documents the key:
- `Teya.Checkout.create_session/2`
- `Teya.PayByLink.create/2`
- `Teya.Transaction.create/2`
- `Teya.Capture.create/3`
- `Teya.Refund.create/2`
- `Teya.Moto.create/2`
- `Teya.CardPresent.create/2`
- `Teya.POSLink.Payment.create/2`
- `Teya.POSLink.Refund.create/2`
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:
- **POSLink payment requests** (`Teya.POSLink.Payment.create/2`): list the
store's recent ones with `Teya.POSLink.Payment.list/1`, narrowed by
`terminal_id` and `start_date_time`, and look for your
`merchant_reference`. This lists only payment requests, not refunds made
with `Teya.POSLink.Refund.create/2`.
- **Card-present and MOTO payments** (`Teya.CardPresent.create/2`,
`Teya.Moto.create/2`): reverse by the original key with
`Teya.Reversal.create/2` and `"reversal_reason" =>
"COMMUNICATION_REVERSAL"`. Start again with a new key only once the
reversal's `"status"` is `"SUCCESS"`. `"PENDING"` or `"ACKNOWLEDGED"` means
Teya has not finished it yet, so the payment may still stand. A
`"FAILURE"` or an error does not prove there was no payment to reverse.
In those cases check in the Teya portal before starting again.
- **Checkout sessions and payment links** (`Teya.Checkout.create_session/2`,
`Teya.PayByLink.create/2`): creating one charges nothing until the
customer pays, so a second one is a smaller risk. Still, send only one of
them to the customer.
- **Everything else** (`Teya.Transaction.create/2`, `Teya.Capture.create/3`,
`Teya.Refund.create/2`, `Teya.POSLink.Refund.create/2`): the library
cannot look these up without the id the lost answer held. Check in the
Teya portal, or keep sending with the same key.
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:
```elixir
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{}}`:
```elixir
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`:
```elixir
{: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 network error leaves `status` and `code` `nil`, with the exception in
`reason`, such as
`%Teya.Error{status: nil, reason: %Req.TransportError{reason: :timeout}}`.
You cannot tell whether Teya acted on the request. See [Retries](#retries)
for how to check before sending a payment again.
- When the library could not get an access token, `reason` is
`{:no_token, cause}`: nothing was sent to Teya, so sending again is safe.
If the token endpoint answered, `status` and `code` are its answer.
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:
```elixir
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`](#retries) 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:
```elixir
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
- Elixir 1.17+, Erlang/OTP 25+ (see `.tool-versions` for exact versions used locally)
- [Homebrew](https://brew.sh) (macOS/Linux) for dev tooling
### Setup
Install dependencies and git hooks:
```bash
./bin/setup
mix setup
```
`./bin/setup` installs [actionlint](https://github.com/rhysd/actionlint),
[check-jsonschema](https://github.com/python-jsonschema/check-jsonschema), and
[Lefthook](https://github.com/evilmartians/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
```sh
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.