Teya
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:
- the set named with the
:credentialsoption, if given; - otherwise
:poslinkfor a POSLink call, or:onlinefor any other, if that set is configured; - otherwise the top-level
:client_id,:client_secretand: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 |
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
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
Pay By Link
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 (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), 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, callTeya.POSLink.Payment.get/2to fetch the current status, or callsubscribe/2again with the samepayment_request_id.
Cancel a payment
{:ok, _} = Teya.POSLink.Payment.cancel(payment_request_id)
POSLink refunds
{: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:
Teya.Checkout.create_session/2Teya.PayByLink.create/2Teya.Transaction.create/2Teya.Capture.create/3Teya.Refund.create/2Teya.Moto.create/2Teya.CardPresent.create/2Teya.POSLink.Payment.create/2Teya.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 withTeya.POSLink.Payment.list/1, narrowed byterminal_idandstart_date_time, and look for yourmerchant_reference. This lists only payment requests, not refunds made withTeya.POSLink.Refund.create/2. - Card-present and MOTO payments (
Teya.CardPresent.create/2,Teya.Moto.create/2): reverse by the original key withTeya.Reversal.create/2and"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:
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 network error leaves
statusandcodenil, with the exception inreason, such as%Teya.Error{status: nil, reason: %Req.TransportError{reason: :timeout}}. You cannot tell whether Teya acted on the request. See Retries for how to check before sending a payment again. - When the library could not get an access token,
reasonis{:no_token, cause}: nothing was sent to Teya, so sending again is safe. If the token endpoint answered,statusandcodeare 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:
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
- Elixir 1.17+, Erlang/OTP 25+ (see
.tool-versionsfor exact versions used locally) - Homebrew (macOS/Linux) for dev tooling
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.