# 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.

<div class="doc-switcher" data-doc-switcher>
<div class="playground-selectors">
<div class="playground-selector-group">
<label>Flow</label>
<div class="playground-tabs" data-doc-axis="flow">
<button class="playground-tab active" data-doc-value="cit">Customer-initiated</button>
<button class="playground-tab" data-doc-value="mit">Merchant-initiated</button>
</div>
</div>
<div class="playground-selector-group">
<label>Backend</label>
<div class="playground-tabs" data-doc-axis="lang">
<button class="playground-tab active" data-doc-value="node">Node.js</button>
<button class="playground-tab" data-doc-value="python">Python</button>
<button class="playground-tab" data-doc-value="php">PHP</button>
<button class="playground-tab" data-doc-value="go">Go</button>
<button class="playground-tab" data-doc-value="java">Java</button>
</div>
</div>
</div>
</div>

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.

<div data-doc-when="flow:cit">

## 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:

```javascript
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`.

<div data-doc-when="lang:node">

```javascript
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' });
});
```

</div>

<div data-doc-when="lang:python">

```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")
```

</div>

<div data-doc-when="lang:php">

```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']);
```

</div>

<div data-doc-when="lang:go">

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

</div>

<div data-doc-when="lang:java">

```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");
}
```

</div>

## Step 3 — Handle one of two responses

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

```json
{
  "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:

```json
{
  "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:

```javascript
function presentChallenge(html) {
  // Full page — simplest, and the most reliable across issuers.
  document.open();
  document.write(html);
  document.close();
}
```

```javascript
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:

<div data-doc-when="lang:node">

```javascript
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}`);
});
```

</div>

<div data-doc-when="lang:python">

```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']}")
```

</div>

<div data-doc-when="lang:php">

```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']));
```

</div>

<div data-doc-when="lang:go">

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

</div>

<div data-doc-when="lang:java">

```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;
}
```

</div>

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

</div>

<div data-doc-when="flow:mit">

## 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](/partners/online-payments/embedded-components/checkout/customize) for how the wallet token gets created.

<div data-doc-when="lang:node">

```javascript
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',
]);
```

</div>

<div data-doc-when="lang:python">

```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
```

</div>

<div data-doc-when="lang:php">

```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;
}
```

</div>

<div data-doc-when="lang:go">

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

</div>

<div data-doc-when="lang:java">

```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;
}
```

</div>

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:

```json
{
  "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`:

```json
{
  "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.

</div>

## 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 |

## Related

- [Card on File](/partners/online-payments/saved-payments/overview) — the wider flow and how the card gets saved
- [Webhooks](/partners/online-payments/webhooks) — the `payment.succeeded.v1` payload
- [Payment Actions](/partners/online-payments/payment-actions) — captures, refunds, and cancellations
- [Testing](/partners/online-payments/testing) — test cards and integration checklist
- [API Reference](/apis/online-payments/apis) — full endpoint documentation

<script src="/js/doc-switcher.js"></script>
