Teya

Test StatusCoverage StatusHex VersionHex Docs

Elixir client for the Teya API.

Installation

Add teya to your dependencies in mix.exs:

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

Configuration

# config/runtime.exs
config :teya,
  client_id: System.fetch_env!("TEYA_CLIENT_ID"),
  client_secret: System.fetch_env!("TEYA_CLIENT_SECRET"),
  token_url: "https://identity.teya.com/connect/token",
  base_url: "https://api.teya.com",
  scopes: [
    # list only the scopes your application needs — see table below
  ]

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

Scope reference

Scope Library function
checkout/sessions/createTeya.Checkout.create_session/2
checkout/sessions/id/getTeya.Checkout.get_session/1
payment-links/createTeya.PayByLink.create/2
payment-links/id/getTeya.PayByLink.get/1
payment-links/id/updateTeya.PayByLink.update/2
transactions/online/createTeya.Transaction.create/2
transactions/online/id/getTeya.Transaction.get/1
captures/createTeya.Capture.create/3
refunds/createTeya.Refund.create/2
transactions/id/receipts/createTeya.Receipt.create/3
token/deleteTeya.Token.delete/3
poslink/stores/getTeya.POSLink.Store.list/1
poslink/stores/id/terminals/getTeya.POSLink.Store.list_terminals/2
poslink/payment-requests/createTeya.POSLink.Payment.create/2
poslink/payment-requests/id/getTeya.POSLink.Payment.subscribe/2
poslink/payment-requests/id/updateTeya.POSLink.Payment.cancel/2
poslink/payment-requests/getTeya.POSLink.Payment.list/1
poslink/refunds/createTeya.POSLink.Refund.create/2
poslink/receipt-requests/createTeya.POSLink.Receipt.create/2
poslink/receipt-requests/id/status/getTeya.POSLink.Receipt.subscribe_status/2

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})

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.

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"]

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"}
}

{:ok, %{"payment_request_id" => id}} = Teya.POSLink.Payment.create(params)
{:ok, _task} = Teya.POSLink.Payment.subscribe(id, self())

receive do
  {:poslink_payment, ^id, "full", %{"status" => "SUCCESSFUL"} = data} ->
    # payment complete — data contains full transaction metadata
  {:poslink_payment, ^id, _type, %{"status" => "FAILED"}} ->
    # card declined or terminal error
  {:poslink_payment, ^id, _type, %{"status" => status}} when status in ["NEW", "IN_PROGRESS"] ->
    # intermediate state — keep waiting
  {:poslink_payment_error, ^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().

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, id, reason} and exits — there is no automatic reconnection. To recover, call Teya.POSLink.Payment.list/1 to poll 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)

POSLink refunds

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

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} = Teya.POSLink.Receipt.subscribe_status(receipt_id, self())

receive do
  {:poslink_receipt, ^receipt_id, _type, %{"status" => "PRINTED"}} -> :ok
  {:poslink_receipt, ^receipt_id, _type, %{"status" => "FAILED"}}  -> handle_failure()
  {:poslink_receipt_error, ^receipt_id, reason}                    -> handle_error(reason)
end

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)

Error Handling

All functions 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: "UNAUTHORISED"}} -> {:error, :unauthorized}
  {:error, %Teya.Error{status: status}} -> {:error, status}
end

Troubleshooting

Rate limiting (TOO_MANY_REQUESTS)

Teya returns HTTP 429 when you exceed the rate limit. Back off exponentially 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, 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.list/1 (filter by payment_request_id), 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 schedules a retry after 10 seconds. While retrying, API calls return {:error, reason}. The cached token (if any) remains usable until it expires. Once connectivity is restored, the retry succeeds automatically — no restart required.

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.