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 |
Related
- Card on File — the wider flow and how the card gets saved
- Webhooks — the
payment.succeeded.v1payload - Payment Actions — captures, refunds, and cancellations
- Testing — test cards and integration checklist
- API Reference — full endpoint documentation