Charge a Saved Payment Method

Once you have a token_id from a saved card, you charge it through POST /v3/transactions/online.

The initiator field decides how the charge behaves. CUSTOMER means the customer is present and the issuer may demand 3DS — which you present, because there is no Teya-hosted page in this flow. MERCHANT means the customer is absent, so there is no 3DS, no redirect, and the response is immediate and final.

Every charge also declares the mandate it is made under:

recurring_payment_type Use it for Valid with
CARD_ON_FILE unscheduled charges — top-ups, usage-based billing, one-click checkout CUSTOMER and MERCHANT
RECURRING charges on a fixed schedule — subscriptions MERCHANT only
INSTALLMENT a fixed number of scheduled payments for a single purchase MERCHANT only

You pick this per charge, not when the card was saved. The same token can back a subscription this month and a one-click purchase tomorrow.

A card saved through Apple Pay is a wallet token, not a card token. The webhook that delivered it says so in token_response.token_type: "WALLET_TOKEN", and wallet tokens are charged merchant-initiated only. See the merchant-initiated flow below for the request shape. Tokens saved from card details have token_type: "CARD_TOKEN", which is also the default when you omit the field.

Customer-initiated

Step 1 — Collect browser data

The customer is on your site paying with a saved card. 3DS needs to know about their browser, so gather this on the page before calling the API:

const browserData = {
  accept_language: navigator.language,
  screen_height: window.screen.height,
  screen_width: window.screen.width,
  user_agent: navigator.userAgent,
};

const res = await fetch('/checkout/pay-with-saved-card', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ tokenId: selectedCard.tokenId, browserData }),
});

const result = await res.json();
if (result.status === 'challenge') {
  presentChallenge(result.html);
}

Step 2 — Charge the token

recurring_payment_type must be CARD_ON_FILE here. RECURRING and INSTALLMENT are rejected with a 400 when the initiator is CUSTOMER.

Node.js

app.post('/checkout/pay-with-saved-card', async (req, res) => {
  const { tokenId, browserData } = req.body;
  const accessToken = await getAccessToken();

  const chargeRes = await fetch('https://api.teya.xyz/v3/transactions/online', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${accessToken}`,
      'Idempotency-Key': crypto.randomUUID(),
    },
    body: JSON.stringify({
      type: 'SALE',
      amount: { currency: 'GBP', value: 4999 },
      store_id: process.env.TEYA_STORE_ID,
      initiator: 'CUSTOMER',
      payment_method: {
        type: 'TOKEN',
        token: { token_id: tokenId, recurring_payment_type: 'CARD_ON_FILE' },
      },
      browser_data: { ...browserData, ip_address: req.ip },
      redirect_url: 'https://example.shop/checkout/3ds-return',
    }),
  });

  const result = await chargeRes.json();

  if (result.type === 'REDIRECT_TRANSACTION_RESPONSE') {
    const { authentication_id, redirect_html } = result.redirect_transaction_response;
    await orders.markPendingAuthentication(req.session.orderId, authentication_id);
    return res.json({ status: 'challenge', html: redirect_html });
  }

  const tx = result.online_transaction;
  return res.json({ status: tx.status === 'SUCCESS' ? 'paid' : 'declined' });
});

Python

@app.route("/checkout/pay-with-saved-card", methods=["POST"])
def pay_with_saved_card():
    payload = request.get_json()
    browser_data = payload["browserData"] | {"ip_address": request.remote_addr}
    access_token = get_access_token()

    res = requests.post(
        "https://api.teya.xyz/v3/transactions/online",
        headers={
            "Authorization": f"Bearer {access_token}",
            "Idempotency-Key": str(uuid.uuid4()),
        },
        json={
            "type": "SALE",
            "amount": {"currency": "GBP", "value": 4999},
            "store_id": os.environ["TEYA_STORE_ID"],
            "initiator": "CUSTOMER",
            "payment_method": {
                "type": "TOKEN",
                "token": {
                    "token_id": payload["tokenId"],
                    "recurring_payment_type": "CARD_ON_FILE",
                },
            },
            "browser_data": browser_data,
            "redirect_url": "https://example.shop/checkout/3ds-return",
        },
    )

    result = res.json()

    if result["type"] == "REDIRECT_TRANSACTION_RESPONSE":
        redirect_response = result["redirect_transaction_response"]
        mark_pending_authentication(session["order_id"], redirect_response["authentication_id"])
        return jsonify(status="challenge", html=redirect_response["redirect_html"])

    tx = result["online_transaction"]
    return jsonify(status="paid" if tx["status"] == "SUCCESS" else "declined")

PHP

<?php
// POST /checkout/pay-with-saved-card

$payload = json_decode(file_get_contents('php://input'), true);
$browserData = $payload['browserData'] + ['ip_address' => $_SERVER['REMOTE_ADDR']];
$accessToken = getAccessToken();

$ch = curl_init('https://api.teya.xyz/v3/transactions/online');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_USERAGENT => 'Teya-PHP-Client/1.0',
    CURLOPT_HTTPHEADER => [
        'Content-Type: application/json',
        'Authorization: Bearer ' . $accessToken,
        'Idempotency-Key: ' . uniqid('', true),
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'type' => 'SALE',
        'amount' => ['currency' => 'GBP', 'value' => 4999],
        'store_id' => getenv('TEYA_STORE_ID'),
        'initiator' => 'CUSTOMER',
        'payment_method' => [
            'type' => 'TOKEN',
            'token' => [
                'token_id' => $payload['tokenId'],
                'recurring_payment_type' => 'CARD_ON_FILE',
            ],
        ],
        'browser_data' => $browserData,
        'redirect_url' => 'https://example.shop/checkout/3ds-return',
    ]),
]);

$result = json_decode(curl_exec($ch), true);
curl_close($ch);

header('Content-Type: application/json');

if ($result['type'] === 'REDIRECT_TRANSACTION_RESPONSE') {
    $redirectResponse = $result['redirect_transaction_response'];
    markPendingAuthentication($_SESSION['order_id'], $redirectResponse['authentication_id']);
    echo json_encode(['status' => 'challenge', 'html' => $redirectResponse['redirect_html']]);
    exit;
}

$tx = $result['online_transaction'];
echo json_encode(['status' => $tx['status'] === 'SUCCESS' ? 'paid' : 'declined']);

Go

func payWithSavedCardHandler(w http.ResponseWriter, r *http.Request) {
    var payload struct {
        TokenID     string                 `json:"tokenId"`
        BrowserData map[string]interface{} `json:"browserData"`
    }
    json.NewDecoder(r.Body).Decode(&payload)

    payload.BrowserData["ip_address"] = clientIP(r)
    accessToken, _ := getAccessToken()

    body, _ := json.Marshal(map[string]interface{}{
        "type":      "SALE",
        "amount":    map[string]interface{}{"currency": "GBP", "value": 4999},
        "store_id":  os.Getenv("TEYA_STORE_ID"),
        "initiator": "CUSTOMER",
        "payment_method": map[string]interface{}{
            "type": "TOKEN",
            "token": map[string]interface{}{
                "token_id":               payload.TokenID,
                "recurring_payment_type": "CARD_ON_FILE",
            },
        },
        "browser_data": payload.BrowserData,
        "redirect_url": "https://example.shop/checkout/3ds-return",
    })

    req, _ := http.NewRequest("POST",
        "https://api.teya.xyz/v3/transactions/online", bytes.NewBuffer(body))
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Authorization", "Bearer "+accessToken)
    req.Header.Set("Idempotency-Key", uuid.New().String())

    resp, _ := http.DefaultClient.Do(req)
    defer resp.Body.Close()

    var result struct {
        Type              string `json:"type"`
        OnlineTransaction *struct {
            Status string `json:"status"`
        } `json:"online_transaction"`
        RedirectTransactionResponse *struct {
            AuthenticationID string `json:"authentication_id"`
            RedirectHTML     string `json:"redirect_html"`
        } `json:"redirect_transaction_response"`
    }
    json.NewDecoder(resp.Body).Decode(&result)

    w.Header().Set("Content-Type", "application/json")

    if result.Type == "REDIRECT_TRANSACTION_RESPONSE" {
        markPendingAuthentication(sessionOrderID(r), result.RedirectTransactionResponse.AuthenticationID)
        json.NewEncoder(w).Encode(map[string]string{
            "status": "challenge",
            "html":   result.RedirectTransactionResponse.RedirectHTML,
        })
        return
    }

    status := "declined"
    if result.OnlineTransaction.Status == "SUCCESS" {
        status = "paid"
    }
    json.NewEncoder(w).Encode(map[string]string{"status": status})
}

Java

@PostMapping("/checkout/pay-with-saved-card")
public Map<String, String> payWithSavedCard(
        @RequestBody ChargeRequest payload,
        HttpServletRequest servletRequest,
        HttpSession session) throws Exception {

    String accessToken = getAccessToken();

    ObjectNode browserData = mapper.valueToTree(payload.browserData());
    browserData.put("ip_address", servletRequest.getRemoteAddr());

    ObjectNode body = mapper.createObjectNode();
    body.put("type", "SALE");
    body.set("amount", mapper.createObjectNode()
        .put("currency", "GBP").put("value", 4999));
    body.put("store_id", System.getenv("TEYA_STORE_ID"));
    body.put("initiator", "CUSTOMER");
    body.set("payment_method", mapper.createObjectNode()
        .put("type", "TOKEN")
        .set("token", mapper.createObjectNode()
            .put("token_id", payload.tokenId())
            .put("recurring_payment_type", "CARD_ON_FILE")));
    body.set("browser_data", browserData);
    body.put("redirect_url", "https://example.shop/checkout/3ds-return");

    HttpRequest req = HttpRequest.newBuilder()
        .uri(URI.create("https://api.teya.xyz/v3/transactions/online"))
        .header("Content-Type", "application/json")
        .header("Authorization", "Bearer " + accessToken)
        .header("Idempotency-Key", UUID.randomUUID().toString())
        .POST(BodyPublishers.ofString(body.toString()))
        .build();

    JsonNode result = mapper.readTree(
        client.send(req, HttpResponse.BodyHandlers.ofString()).body());

    if ("REDIRECT_TRANSACTION_RESPONSE".equals(result.get("type").asText())) {
        JsonNode redirect = result.get("redirect_transaction_response");
        markPendingAuthentication((String) session.getAttribute("orderId"),
            redirect.get("authentication_id").asText());
        return Map.of("status", "challenge",
            "html", redirect.get("redirect_html").asText());
    }

    JsonNode tx = result.get("online_transaction");
    return Map.of("status",
        "SUCCESS".equals(tx.get("status").asText()) ? "paid" : "declined");
}

Step 3 — Handle one of two responses

Frictionless — the issuer waved it through, HTTP 201, and you are done:

{
  "type": "ONLINE_TRANSACTION",
  "online_transaction": {
    "authentication_id": "3d7e91b2-6c48-4f19-8a52-1e7b4d90c3f6",
    "transaction_id": "tr_9f2c1a70-4d88-4b3e-91ad-6c7e2f5b8d40",
    "status": "SUCCESS",
    "status_reason": null,
    "issuer_result": { "response_code": "00", "approval_code": "123456" }
  }
}

Challenge required — HTTP 202, status PENDING, and a blob of HTML you must render:

{
  "type": "REDIRECT_TRANSACTION_RESPONSE",
  "redirect_transaction_response": {
    "authentication_id": "3d7e91b2-6c48-4f19-8a52-1e7b4d90c3f6",
    "transaction_id": "tr_9f2c1a70-4d88-4b3e-91ad-6c7e2f5b8d40",
    "status": "PENDING",
    "redirect_html": "<html><body><form method=\"POST\" action=\"https://acs.issuer.example/challenge\">...</form><script>document.forms[0].submit();</script></body></html>"
  }
}

Step 4 — Present the challenge

redirect_html is a self-submitting form aimed at the issuer's authentication server. Write it into a document and let it run:

function presentChallenge(html) {
  // Full page — simplest, and the most reliable across issuers.
  document.open();
  document.write(html);
  document.close();
}
function presentChallengeInFrame(html) {
  // Or in an iframe, if you want to keep your page around it.
  const frame = document.getElementById('threeds-frame');
  frame.contentDocument.open();
  frame.contentDocument.write(html);
  frame.contentDocument.close();
}

The customer authenticates with their bank, and the issuer sends the browser to the redirect_url you supplied.

Step 5 — Confirm the final status

On the redirect_url handler, fetch the transaction by the authentication_id you stored:

Node.js

app.get('/checkout/3ds-return', async (req, res) => {
  const order = await orders.pendingFor(req.session.orderId);
  const accessToken = await getAccessToken();

  const txRes = await fetch(
    `https://api.teya.xyz/v2/transactions/online/${order.authenticationId}`,
    { headers: { Authorization: `Bearer ${accessToken}` } }
  );

  const tx = await txRes.json();

  if (tx.status === 'SUCCESS') {
    await orders.fulfil(order.id, tx.transaction_id);
    return res.redirect('/checkout/thank-you');
  }
  if (tx.status === 'PENDING') {
    // Issuer has not settled yet — let the webhook finish the job.
    return res.redirect('/checkout/processing');
  }

  await orders.fail(order.id, tx.status_reason);
  res.redirect(`/checkout/failed?reason=${tx.status_reason}`);
});

Python

@app.route("/checkout/3ds-return")
def three_ds_return():
    order = pending_order(session["order_id"])
    access_token = get_access_token()

    res = requests.get(
        f"https://api.teya.xyz/v2/transactions/online/{order.authentication_id}",
        headers={"Authorization": f"Bearer {access_token}"},
    )
    tx = res.json()

    if tx["status"] == "SUCCESS":
        fulfil_order(order.id, tx["transaction_id"])
        return redirect("/checkout/thank-you")

    if tx["status"] == "PENDING":
        # Issuer has not settled yet — let the webhook finish the job.
        return redirect("/checkout/processing")

    fail_order(order.id, tx["status_reason"])
    return redirect(f"/checkout/failed?reason={tx['status_reason']}")

PHP

<?php
// GET /checkout/3ds-return

$order = pendingOrder($_SESSION['order_id']);
$accessToken = getAccessToken();

$ch = curl_init('https://api.teya.xyz/v2/transactions/online/' . $order['authentication_id']);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_USERAGENT => 'Teya-PHP-Client/1.0',
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $accessToken],
]);
$tx = json_decode(curl_exec($ch), true);
curl_close($ch);

if ($tx['status'] === 'SUCCESS') {
    fulfilOrder($order['id'], $tx['transaction_id']);
    header('Location: /checkout/thank-you');
    exit;
}

if ($tx['status'] === 'PENDING') {
    // Issuer has not settled yet — let the webhook finish the job.
    header('Location: /checkout/processing');
    exit;
}

failOrder($order['id'], $tx['status_reason']);
header('Location: /checkout/failed?reason=' . urlencode($tx['status_reason']));

Go

func threeDSReturnHandler(w http.ResponseWriter, r *http.Request) {
    order := pendingOrder(sessionOrderID(r))
    accessToken, _ := getAccessToken()

    req, _ := http.NewRequest("GET",
        "https://api.teya.xyz/v2/transactions/online/"+order.AuthenticationID, nil)
    req.Header.Set("Authorization", "Bearer "+accessToken)

    resp, _ := http.DefaultClient.Do(req)
    defer resp.Body.Close()

    var tx struct {
        Status        string `json:"status"`
        StatusReason  string `json:"status_reason"`
        TransactionID string `json:"transaction_id"`
    }
    json.NewDecoder(resp.Body).Decode(&tx)

    switch tx.Status {
    case "SUCCESS":
        fulfilOrder(order.ID, tx.TransactionID)
        http.Redirect(w, r, "/checkout/thank-you", http.StatusSeeOther)
    case "PENDING":
        // Issuer has not settled yet — let the webhook finish the job.
        http.Redirect(w, r, "/checkout/processing", http.StatusSeeOther)
    default:
        failOrder(order.ID, tx.StatusReason)
        http.Redirect(w, r, "/checkout/failed?reason="+tx.StatusReason, http.StatusSeeOther)
    }
}

Java

@GetMapping("/checkout/3ds-return")
public String threeDsReturn(HttpSession session) throws Exception {
    Order order = pendingOrder((String) session.getAttribute("orderId"));
    String accessToken = getAccessToken();

    HttpRequest req = HttpRequest.newBuilder()
        .uri(URI.create("https://api.teya.xyz/v2/transactions/online/"
            + order.authenticationId()))
        .header("Authorization", "Bearer " + accessToken)
        .GET()
        .build();

    JsonNode tx = mapper.readTree(
        client.send(req, HttpResponse.BodyHandlers.ofString()).body());

    String status = tx.get("status").asText();

    if ("SUCCESS".equals(status)) {
        fulfilOrder(order.id(), tx.get("transaction_id").asText());
        return "redirect:/checkout/thank-you";
    }
    if ("PENDING".equals(status)) {
        // Issuer has not settled yet — let the webhook finish the job.
        return "redirect:/checkout/processing";
    }

    String reason = tx.get("status_reason").asText();
    failOrder(order.id(), reason);
    return "redirect:/checkout/failed?reason=" + reason;
}

A failed challenge shows up as FAILURE with a status_reason of INCORRECT_CARD_AUTHENTICATION or CANNOT_VALIDATE_AUTHENTICATION.

Merchant-initiated

Charge the token

The renewal date arrives, the installment is due, usage crosses a threshold. There is no customer and no browser, so there is no 3DS, no challenge, and no redirect — your backend charges the token and gets a final answer immediately.

Send the token_type you stored with the token: CARD_TOKEN for a card saved from card details, WALLET_TOKEN for a card saved through Apple Pay. Without it Teya looks the id up as a card token, and a wallet token gets 404 Network token not found. See Saving an Apple Pay card for how the wallet token gets created.

Node.js

async function chargeSubscription(subscription) {
  const accessToken = await getAccessToken();

  const res = await fetch('https://api.teya.xyz/v3/transactions/online', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${accessToken}`,
      'Idempotency-Key': subscription.currentPeriodKey,
    },
    body: JSON.stringify({
      type: 'SALE',
      amount: { currency: 'GBP', value: subscription.amount },
      store_id: process.env.TEYA_STORE_ID,
      initiator: 'MERCHANT',
      payment_method: {
        type: 'TOKEN',
        token: {
          token_id: subscription.tokenId,
          token_type: subscription.tokenType,
          recurring_payment_type: 'RECURRING',
        },
      },
    }),
  });

  const { online_transaction: tx } = await res.json();

  if (tx.status === 'SUCCESS') {
    await subscriptions.extend(subscription.id, tx.transaction_id);
  } else if (RETRYABLE.has(tx.status_reason)) {
    await dunning.scheduleRetry(subscription.id, tx.status_reason);
  } else {
    await subscriptions.suspend(subscription.id, tx.status_reason);
  }

  return tx;
}

const RETRYABLE = new Set([
  'INSUFFICIENT_FUNDS',
  'TEMPORARY_ISSUE_RETRY',
  'PROCESSOR_UNAVAILABLE',
  'TIMEOUT',
]);

Python

RETRYABLE = {
    "INSUFFICIENT_FUNDS",
    "TEMPORARY_ISSUE_RETRY",
    "PROCESSOR_UNAVAILABLE",
    "TIMEOUT",
}

def charge_subscription(subscription):
    access_token = get_access_token()

    res = requests.post(
        "https://api.teya.xyz/v3/transactions/online",
        headers={
            "Authorization": f"Bearer {access_token}",
            "Idempotency-Key": subscription.current_period_key,
        },
        json={
            "type": "SALE",
            "amount": {"currency": "GBP", "value": subscription.amount},
            "store_id": os.environ["TEYA_STORE_ID"],
            "initiator": "MERCHANT",
            "payment_method": {
                "type": "TOKEN",
                "token": {
                    "token_id": subscription.token_id,
                    "token_type": subscription.token_type,
                    "recurring_payment_type": "RECURRING",
                },
            },
        },
    )

    tx = res.json()["online_transaction"]

    if tx["status"] == "SUCCESS":
        extend_subscription(subscription.id, tx["transaction_id"])
    elif tx["status_reason"] in RETRYABLE:
        schedule_retry(subscription.id, tx["status_reason"])
    else:
        suspend_subscription(subscription.id, tx["status_reason"])

    return tx

PHP

<?php

const RETRYABLE = [
    'INSUFFICIENT_FUNDS',
    'TEMPORARY_ISSUE_RETRY',
    'PROCESSOR_UNAVAILABLE',
    'TIMEOUT',
];

function chargeSubscription(array $subscription): array {
    $accessToken = getAccessToken();

    $ch = curl_init('https://api.teya.xyz/v3/transactions/online');
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_USERAGENT => 'Teya-PHP-Client/1.0',
        CURLOPT_HTTPHEADER => [
            'Content-Type: application/json',
            'Authorization: Bearer ' . $accessToken,
            'Idempotency-Key: ' . $subscription['current_period_key'],
        ],
        CURLOPT_POSTFIELDS => json_encode([
            'type' => 'SALE',
            'amount' => ['currency' => 'GBP', 'value' => $subscription['amount']],
            'store_id' => getenv('TEYA_STORE_ID'),
            'initiator' => 'MERCHANT',
            'payment_method' => [
                'type' => 'TOKEN',
                'token' => [
                    'token_id' => $subscription['token_id'],
                    'token_type' => $subscription['token_type'],
                    'recurring_payment_type' => 'RECURRING',
                ],
            ],
        ]),
    ]);

    $tx = json_decode(curl_exec($ch), true)['online_transaction'];
    curl_close($ch);

    if ($tx['status'] === 'SUCCESS') {
        extendSubscription($subscription['id'], $tx['transaction_id']);
    } elseif (in_array($tx['status_reason'], RETRYABLE, true)) {
        scheduleRetry($subscription['id'], $tx['status_reason']);
    } else {
        suspendSubscription($subscription['id'], $tx['status_reason']);
    }

    return $tx;
}

Go

var retryable = map[string]bool{
    "INSUFFICIENT_FUNDS":    true,
    "TEMPORARY_ISSUE_RETRY": true,
    "PROCESSOR_UNAVAILABLE": true,
    "TIMEOUT":               true,
}

func chargeSubscription(sub Subscription) error {
    accessToken, err := getAccessToken()
    if err != nil {
        return err
    }

    body, _ := json.Marshal(map[string]interface{}{
        "type":      "SALE",
        "amount":    map[string]interface{}{"currency": "GBP", "value": sub.Amount},
        "store_id":  os.Getenv("TEYA_STORE_ID"),
        "initiator": "MERCHANT",
        "payment_method": map[string]interface{}{
            "type": "TOKEN",
            "token": map[string]interface{}{
                "token_id":               sub.TokenID,
                "token_type":             sub.TokenType,
                "recurring_payment_type": "RECURRING",
            },
        },
    })

    req, _ := http.NewRequest("POST",
        "https://api.teya.xyz/v3/transactions/online", bytes.NewBuffer(body))
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Authorization", "Bearer "+accessToken)
    req.Header.Set("Idempotency-Key", sub.CurrentPeriodKey)

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    var result struct {
        OnlineTransaction struct {
            Status        string `json:"status"`
            StatusReason  string `json:"status_reason"`
            TransactionID string `json:"transaction_id"`
        } `json:"online_transaction"`
    }
    json.NewDecoder(resp.Body).Decode(&result)
    tx := result.OnlineTransaction

    switch {
    case tx.Status == "SUCCESS":
        return extendSubscription(sub.ID, tx.TransactionID)
    case retryable[tx.StatusReason]:
        return scheduleRetry(sub.ID, tx.StatusReason)
    default:
        return suspendSubscription(sub.ID, tx.StatusReason)
    }
}

Java

private static final Set<String> RETRYABLE = Set.of(
    "INSUFFICIENT_FUNDS",
    "TEMPORARY_ISSUE_RETRY",
    "PROCESSOR_UNAVAILABLE",
    "TIMEOUT"
);

public JsonNode chargeSubscription(Subscription sub) throws Exception {
    String accessToken = getAccessToken();

    String body = """
        {
            "type": "SALE",
            "amount": { "currency": "GBP", "value": %d },
            "store_id": "%s",
            "initiator": "MERCHANT",
            "payment_method": {
                "type": "TOKEN",
                "token": {
                    "token_id": "%s",
                    "token_type": "%s",
                    "recurring_payment_type": "RECURRING"
                }
            }
        }
        """.formatted(sub.amount(), System.getenv("TEYA_STORE_ID"), sub.tokenId(), sub.tokenType());

    HttpRequest req = HttpRequest.newBuilder()
        .uri(URI.create("https://api.teya.xyz/v3/transactions/online"))
        .header("Content-Type", "application/json")
        .header("Authorization", "Bearer " + accessToken)
        .header("Idempotency-Key", sub.currentPeriodKey())
        .POST(BodyPublishers.ofString(body))
        .build();

    JsonNode tx = mapper.readTree(
        client.send(req, HttpResponse.BodyHandlers.ofString()).body())
        .get("online_transaction");

    String status = tx.get("status").asText();
    String reason = tx.path("status_reason").asText(null);

    if ("SUCCESS".equals(status)) {
        extendSubscription(sub.id(), tx.get("transaction_id").asText());
    } else if (RETRYABLE.contains(reason)) {
        scheduleRetry(sub.id(), reason);
    } else {
        suspendSubscription(sub.id(), reason);
    }

    return tx;
}

Using the subscription's billing-period key as the Idempotency-Key, rather than a fresh UUID, means a retried or double-fired billing job cannot charge the customer twice for the same period.

The response

201 with a final status — there is no pending state to poll and no redirect to handle:

{
  "type": "ONLINE_TRANSACTION",
  "online_transaction": {
    "authentication_id": "5f7b1a3a-3b7a-4b1a-9b1a-3a3b7a4b1a9b",
    "transaction_id": "tr_c48d0e21-7f56-4a90-bb13-2e5c9a7d1f83",
    "status": "SUCCESS",
    "status_reason": null,
    "issuer_result": { "response_code": "00", "approval_code": "654321" }
  }
}

A decline comes back as 201 too, with status: FAILURE:

{
  "type": "ONLINE_TRANSACTION",
  "online_transaction": {
    "authentication_id": "5f7b1a3a-3b7a-4b1a-9b1a-3a3b7a4b1a9b",
    "transaction_id": "tr_c48d0e21-7f56-4a90-bb13-2e5c9a7d1f83",
    "status": "FAILURE",
    "status_reason": "INSUFFICIENT_FUNDS",
    "issuer_result": { "response_code": "51" }
  }
}

Keep the authentication_id — it is how you fetch this transaction later. The payment.succeeded.v1 webhook fires for merchant-initiated charges too; use it as a reconciliation safety net rather than your primary signal, since the response is already final.

Errors

What you see What it means What to do
400 "RECURRING and INSTALLMENT are only supported for MERCHANT-initiated token transactions" you combined initiator: CUSTOMER with RECURRING or INSTALLMENT customer-present token charges use CARD_ON_FILE
400 on a zero amount value: 0 is only valid for ACCOUNT_VERIFICATION a SALE needs value > 0
400 on browser_data ip_address, screen_height, or screen_width missing send all three, or omit browser_data entirely
404 "Network token not found" you charged a token saved from Apple Pay without token_type send token_type: "WALLET_TOKEN" and initiator: MERCHANT
401 access token missing or expired fetch a new one
403 a scope was silently dropped at token time, or the store lacks tokenization / merchant-initiated enablement compare requested vs granted scopes, then check enablement with Teya
409 the same Idempotency-Key is still in flight wait and retry with the same key
422 idempotency conflict — the key was reused with a different body use a fresh key for a genuinely new charge
201 with status: FAILURE reached the issuer, and the issuer declined read status_reason and apply your retry policy