Migrating from RPG to Embedded Components

For businesses whose website talks directly to RPG (Borgun Payment API). The first half is for the business owner; the second half is for your developer.

Who this guide is for

  • Your code calls URLs containing borgun.is, rpgapi or /api/payment, authenticated with a Private Access Token.
  • You run 3D Secure yourself through /api/mpi/v2/enrollment and /api/mpi/v2/validation.
  • Your checkout page loads BAPIjs / borgun-payment.js to create single-use tokens.
Not you?
Redirects to securepay.borgun.is, payment URLs created in advance, and stored cards charged without the customer present each have their own guide.

Why migrate, and what you gain

RPG runs on Teya's legacy platform, which is retiring in early 2027; onboarding to it stops soon. The new platform is an upgrade, not a like-for-like swap: it removes the parts of RPG that cost the most to build and maintain.

More completed payments — merchants on the new platform generally see higher conversion: modern 3D Secure with fewer challenges, less checkout drop-off, and card retries that keep the sale
No 3D Secure to build — enrollment, challenge and validation run inside the component
No card data on your systems — Teya-hosted fields inside your page, a smaller PCI scope
Payment confirmations that can't get lost — a signed webhook for every successful payment
Apple Pay and currency conversion without extra code
Refunds without a developer, and partial captures on pre-authorisations

Your options

Checkout component Recommended — the closest match to an RPG checkout. The customer stays on your page; one block provides card fields, pay button, 3D Secure, Apple Pay and currency conversion.

  • Card component — a single card field inside your own form with your own pay button. See Embedded Components.
  • Hosted Checkout — the customer is redirected to a Teya page and back. Least code, different customer experience. See Hosted Checkout.
  • Direct API — your servers send card data to Teya, as RPG allows today. Restricted: needs evidence of PCI DSS compliance (SAQ D). Only where components have no equivalent — see the RPG capability reference.

What the migration involves

1 Get credentials
2 Build
3 Test in staging
4 Go live
5 Retire RPG

For a one-off-payments RPG integration this is a small development project; pre-authorisations and custom logic sit at the upper end. RPG keeps working in parallel until you switch it off.

What to send your developer
(1) A link to this page, pointing at the developer guide below. (2) A development store from your Teya account manager, for staging credentials. (3) Two questions to answer back: does the integration use pre-authorisations, and does it use stored cards or recurring payments (multi-use tokens)? Both change the plan.

Developer guide

How an RPG integration maps onto a checkout session and the Checkout component, with the one request you need at each step.

How it works: before and after

Today (RPG)

  1. Your form or BAPIjs collects the card; card data or a single-use token reaches your server.
  2. Your server runs 3D Secure: enrollment, ACS redirect, TermURL callback, validation, MdStatus.
  3. Your server calls POST /api/payment with the ThreeDSecure block and reads ActionCode.
  4. Optionally a PaymentCreate webhook arrives and you call back GET /api/events/{EventId}.

After (Embedded Components)

  1. Your server gets a short-lived access token and creates a checkout session; the response carries a session token.
  2. Your page initialises the SDK with that token and mounts the Checkout component. Card data goes into Teya-hosted fields, never into your DOM or server.
  3. The customer pays in place; 3D Secure runs inside the component.
  4. onSuccess fires for the user experience; a signed webhook confirms the payment to your server and drives fulfilment.

The biggest change: the whole MPI flow and all card-data or single-use-token handling are deleted — the component replaces both.

Before you start

1. Credentials

Ask your Teya account manager for a development store. In the Staging Business Portal, select the store, go to Integrations and create ecommerce API credentials: a Client ID, a Client Secret (shown once) and your Store ID. Repeat at business.teya.com for production; never mix the two. This replaces your Private and Public Access Tokens. See Authenticate.

2. Environments

Staging is api.teya.xyz / id.teya.xyz / business.teya.xyz with developmentMode: true in the SDK; production is the same hosts on .com without the flag. Full table on Embedded Checkout.

3. Webhook

Teya sends a signed payment.succeeded.v1 event to a public HTTPS endpoint with no authentication on it.

Register the URL in the Business Portal next to your credentials; the portal issues a public key per webhook for verifying signatures.

Unlike RPG's PaymentCreate / Refund / Capture events, only successful customer payments produce a webhook today; refunds and captures you trigger return their outcome in the API response. See Webhooks.

4. Find the legacy code

Search for borgun.is, rpgapi, /api/payment, /api/token/single, /api/mpi, TransactionType, ActionCode, MdStatus, TermURL, MpiToken, BAPIjs, borgun-payment, PrivateAccessToken, BorgunRPGNet, RPGClient. Each hit is a line on the to-do list. The .NET SDK is a thin wrapper over the same endpoints, so the mapping below applies to its methods one-to-one; the new platform has no server-side SDK — plain HTTPS on the server, the JS SDK in the browser.

5. Ground rules

Complete a payment, webhook and refund end to end in staging before touching production code
Keep RPG live behind a flag for a day or two after switching, so rollback is a config change
Never expose the Client Secret in browser code or repositories
Fulfil orders from the webhook, never from a browser callback alone

What replaces what

The RPG capability reference maps each capability to its replacement. This table goes one level down: the calls and fields an RPG developer has to change.

RPG todayNew platformWhat it means for you
Private Access Token (permanent, Basic auth)client_id + client_secret → short-lived Bearer tokenCache and refresh from expires_in (currently 15 min in production, 24 h in staging).
Public Access Token (browser tokenisation)Not needed — the session token comes from your backend per paymentDelete with the BAPIjs code.
POST /api/payment, TransactionType: "Sale"POST /v2/checkout/sessions, type: "SALE" + the componentThe server sends intent only — amount, currency, reference. No card data, no 3DS block.
POST /api/payment, TransactionType: "PreAuthorization"type: "PRE_AUTHORISATION" on the same endpointSee Pre-authorisations below.
PUT /api/payment/{id}/capture (full only)POST /v1/transactions/{id}/capture, {"amount": n}Partial capture gained. Once per authorisation; the remainder is released.
PUT /api/payment/{id}/cancelPOST /v3/refunds on the uncaptured paymentA refund before capture reverses the authorisation. No separate cancel.
PUT /api/payment/{id}/refund + PartialAmountPOST /v3/refunds with transaction_id, amount — or the Business PortalSame semantics: partial, multiple, total ≤ original.
GET /api/payment/{id}GET /v2/checkout/sessions/{session_id}session_status + payment_status instead of TransactionStatus.
POST /api/token/single + BAPIjs.getTokenGone — the component's hosted fields do thisNo token creation, lifetime management or disable calls.
/api/mpi/v2/enrollment, /validation, TermURL, MdStatus, MpiTokenGone — 3D Secure runs inside the component, challenge inlineDelete the whole flow.
POST /api/payment with raw PANDirect API — restricted, PCI SAQ D evidence requiredUse the component unless you have no alternative.
POST /api/token/multi, VerifyCardCard on FileSeparate track — Stored cards & recurring.
Amount — 2 implied decimals, including ISK (100 ISK = 10000)amount.value — minor units per ISO 4217; ISK has no subunit (100 ISK = 100, €54.43 = 5443)Divide RPG ISK amounts by 100. The most likely silent bug in an ISK migration; other currencies are unchanged.
Currency — ISO numeric ("352")amount.currency — ISO alphabetic ("ISK")Lookup change.
OrderId — exactly 12 chars, A–Z/0–9merchant_reference (free-form) + optional metadataWrite the legacy OrderId into metadata during transition if it helps reconciliation.
TransactionDateNot sent — Teya timestamps server-sideDelete.
ActionCode ("000" = approved) + TransactionStatuspayment_status (SUCCESS / FAILED) on the sessionNo action-code tables; declines show in the component and the customer retries in place.
DynamicDescriptorsNot exposed on checkout sessionsTell your Teya contact before migrating if you rely on them.
PaymentCreate webhook + GET /api/events/{EventId}payment.succeeded.v1, RSA-signed (x-teya-signature)Verify by signature instead of calling back.
B-OnlineBusiness PortalCredentials, webhooks, refunds, transaction visibility.

Step-by-step

Steps 1–5 build the Checkout component end to end. Samples are cURL and minimal JavaScript; other languages are on the Embedded Checkout Get Started page.

Step 1 — Fetch an access token (server)

Exchange client_id + client_secret for a Bearer token. This replaces the permanent Private Access Token: cache the token per environment and refresh it before expires_in runs out or on a 401. Scopes for this guide: checkout/sessions/create, checkout/sessions/id/get, refunds/create, plus captures/create for pre-authorisations.

curl -X POST https://id.teya.xyz/oauth/v2/oauth-token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id=$TEYA_CLIENT_ID" \
  -d "client_secret=$TEYA_CLIENT_SECRET" \
  -d "scope=checkout/sessions/create checkout/sessions/id/get refunds/create"
{ "access_token": "eyJhbGciOiJSUzI1NiJ9...", "token_type": "Bearer", "expires_in": 900 }

Full reference: Authenticate.

Step 2 — Create a checkout session (server)

When the customer clicks Pay, your server describes the payment and gets back a session token for the page. This replaces POST /api/payment, but the body carries no card data and no 3DS block — only the intent.

curl -X POST https://api.teya.xyz/v2/checkout/sessions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "store_id": "123e4567-e89b-12d3-a456-426614174000",
    "type": "SALE",
    "amount": { "value": 2490, "currency": "ISK" },
    "merchant_reference": "ORDER-2026-00042",
    "metadata": { "legacy_orderid": "ORDER1230001" }
  }'
{
  "session_id": "14411456-79c8-4d8c-8498-f01907093b83",
  "session_token": "eyJhbGciOiJSUzI1NiJ9...",
  "session_status": "ACTIVE"
}

Things to get right

  • amount.value is an integer in minor units per ISO 4217: 100 ISK = 100, not RPG's 10000.
  • Idempotency-Key is unique per order attempt; retries with the same key can't create duplicate sessions.
  • Create the session when the customer clicks Pay, not when the cart loads — sessions expire.
  • Return only session_token to the browser through a small endpoint of your own — never the access token or the Client Secret.

Full reference: API Reference.

Step 3 — Mount the Checkout component (frontend)

Install @teyaproduct/teya-blocks-js, initialise the SDK with the session token from your backend, and mount the component. This replaces your card form and BAPIjs.getToken: the fields are Teya-hosted, so card data never enters your DOM.

import { initTeyaBlocks } from '@teyaproduct/teya-blocks-js';

// 1. Ask your backend for a session (Step 2)
const { session_token } = await (await fetch('/api/teya-session', { method: 'POST' })).json();

// 2. Initialise the SDK
const teya = await initTeyaBlocks(session_token, {
  locale: 'en',
  appearance: { theme: 'flat' },
  // Routes SDK calls to Teya staging. Remove for production.
  developmentMode: true,
});

// 3. Mount the checkout — card fields, pay button, 3DS, Apple Pay, DCC included
const checkout = teya.elements.create('checkout', {
  onSuccess: (response) => { window.location.href = '/order/confirmation'; },
  onError: (error) => console.error('Payment failed', error),
});

checkout.mount('#checkout-container');

Things to get right

  • A staging session token only works with developmentMode: true; a production token only works without it. Gate the flag on your own environment.
  • Apple Pay needs a one-off domain verification per domain in the Business Portal under Integrations > Apple Pay; card payments work without it.
  • Where did 3D Secure go? Inside the component. Enrollment, the inline challenge, validation and MdStatus are no longer your code.

Full reference: SDK Initialization and Embedded Checkout.

Variant: the Card component

For your own form layout and pay button, create a card element instead and call card.submitPayment() from your button; the session and everything after are the same. No built-in Apple Pay button. See Card Element.

Step 4 — Handle the result

onSuccess / onError are for the user experience: navigate to a confirmation page, show an error state. The webhook (Step 5) is the source of truth for fulfilment. As a fallback (customer closed the browser, webhook delayed), look the session up by ID. This replaces GET /api/payment/{id} and the "only 000 means approved" rule.

curl https://api.teya.xyz/v2/checkout/sessions/$SESSION_ID \
  -H "Authorization: Bearer $ACCESS_TOKEN"
{
  "session_id": "14411456-79c8-4d8c-8498-f01907093b83",
  "session_status": "COMPLETED",
  "amount": { "value": 2490, "currency": "ISK" },
  "type": "SALE",
  "payment_status": "SUCCESS"
}

Things to get right

  • session_status runs ACTIVE → PROCESSING → COMPLETED or EXPIRED; trust payment_status only once COMPLETED.
  • Store the transaction_id from the webhook against the order — it is the key for refunds and receipts.

Full reference: API Reference.

Step 5 — Receive and verify the webhook (server)

Teya POSTs a signed payment.succeeded.v1 event to your registered URL when a payment succeeds — the equivalent of RPG's PaymentCreate, verified by signature instead of a call back to GET /api/events/{EventId}. Verify x-teya-signature (SHA256withRSA, Base64) over the raw request body with the public key issued for this webhook, before parsing anything.

{
  "event": "payment.succeeded.v1",
  "timestamp": "2025-12-02T12:04:22Z",
  "data": {
    "transaction_id": "tr_019adef3-0d07-7543-9b74-efa2d5b6fa2a",
    "session_id": "019adef2-e5b3-7e00-9fe1-61a5a01f3eef",
    "merchant_reference": "ORDER-2026-00042",
    "amount": { "value": 2490, "currency": "ISK" },
    "status": "SUCCESS"
  }
}

Things to get right

  • Be idempotent — the same event can arrive more than once. Return 200 promptly and do slow work asynchronously.
  • Register the route before any JSON body parser so you verify the raw bytes; use the key for this webhook in this environment.
  • Check your handler with the Webhook Signature Validator before go-live.

Full reference: Webhooks.

After the payment

Each operation returns its outcome in the synchronous API response — there is no webhook for refunds or captures today, so record the response.

Refunds

Replaces PUT /api/payment/{id}/refund, same rules: partial and multiple refunds, total ≤ original. 201 is processed; 202 is still processing — retry with the same Idempotency-Key. Staff can also refund from the Business Portal. Payments taken on RPG before cutover stay refundable through B-Online for a transition period.

curl -X POST https://api.teya.xyz/v3/refunds \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "transaction_id": "tr_019adef3-0d07-7543-9b74-efa2d5b6fa2a", "amount": 1000 }'

See Payment Actions.

Payment status and receipts

Status: GET /v2/checkout/sessions/{session_id} (Step 4) replaces GET /api/payment/{id}; the Business Portal covers day-to-day visibility. Receipts are new — POST /v1/transactions/{id}/receipts emails a Teya-generated receipt (scope transactions/id/receipts/create). See Payment Actions.

Pre-authorisations

Skip this if you only send TransactionType: "Sale". Create the session with type: "PRE_AUTHORISATION"; the component flow is unchanged and the funds are held (no DCC on pre-auth). Capture with POST /v1/transactions/{id}/capture and {"amount": n} — any amount up to the authorised total, but exactly once: the remainder is released, so capture the final amount. There is no cancel call: refund the uncaptured payment to reverse the authorisation; untouched holds expire on their own. Pre-authorisations are currently available for certain tourism-related business activities; if your business needs them for another use case, contact your Teya account manager. Capture is API-only today; confirm before migrating pre-auth flows. See Payment Actions.

Testing and going live

Test in staging with developmentMode: true and the cards on the Testing page — frictionless, challenge and declined scenarios are all there. If your development machine isn't publicly reachable, tunnel it so staging webhooks can reach you.

Definition of done

Frictionless card: onSuccess fires, webhook received and verified, session COMPLETED / SUCCESS
Challenge card: the challenge renders inline and the payment completes
Declined card: error shown in the component, customer can retry, order not marked paid
Abandoned or expired session: no webhook, nothing fulfilled, your code creates a fresh session
Amounts: an ISK (and, if relevant, EUR) payment charges exactly the right amount in the Business Portal
Full refund, then two partial refunds; a third exceeding the original fails
Replaying the same webhook doesn't double-fulfil
Integration recovers from a 401 by refreshing the token
Pre-auth users: authorise, capture a partial amount, remainder released
Apple Pay users: domain verified, button renders, test payment completes

Go live

  1. Create production credentials and register the production webhook in the Business Portal at business.teya.com; store the new public key.
  2. Swap hosts by config (.xyz → .com) and remove developmentMode.
  3. Process one small real payment end to end: payment → webhook → refund.
  4. Run RPG in parallel for a day or two behind a flag; watch logs, webhook deliveries and the Business Portal.
  5. Retire the legacy integration: delete the payment, MPI, tokenisation and webhook code paths. Teya retires the RPG access tokens for you; there is nothing to request.

Common errors

SymptomLikely causeFix
Charged amount is 100× too big (ISK)RPG's two-implied-decimals convention carried overDivide legacy ISK amounts by 100
Staging session token rejected by the SDKdevelopmentMode: true missingAdd it in staging, remove in production
401 on API callsAccess token expired — currently 15 min in production, not permanent like the RPG tokenRefresh from expires_in; retry once on 401
403Missing scopeAdd the scope to the token request (Step 1)
409Same Idempotency-Key, different bodyUnique key per order attempt; reuse only for retries of the identical request
422 creating a sessionNumeric currency code or non-integer amountAlphabetic currency; integer amount.value in minor units
Webhook never arrivesURL not public, no valid certificate, auth on the endpoint, or non-200 responsesPublic HTTPS, no auth, return 200 after processing
Signature verification failsParsed body re-serialised, or the wrong keyVerify the raw body; use the key issued for this webhook in this environment
Payment succeeded but not in the Business PortalWrong environmentStaging payments are in business.teya.xyz, production in business.teya.com
Waiting for a refund or capture webhookOnly successful customer payments send oneRead the outcome from the API response
Need help?
Your Teya account manager can provide a development store, confirm pre-authorisation availability and plan the stored-card migration. For integration questions, see Support.

Next steps