---
title: "Pangalink today (2026): iPizza architecture and cloud alternatives"
date: 2026-08-21T12:00:00
tags: [integration, payments, estonia, security, api, tech]
description: "How Estonian bank links actually work: the browser as transport, VK_MAC signatures, versions 008 and 009, services 1011/1012/1111/1911. Why certificates are still issued by hand, how Montonio, LHV, Maksekeskus and EveryPay differ, and which libraries are still alive in PHP, Ruby, Node.js and Go."
---

![Pangalink today (2026): a courier carries a sealed iPizza box between shop and bank](/assets/img/ru/blog/tech/integration/img/pangalink-segodnya-preview.jpg)

Eighteen years ago I [wrote about pangalink](/ru/blog/tech/integration/pangalink/) in an article full of Estonian kroons, Hanza and Sampo banks, and a 750-kroon setup fee. That text now reads like archaeology: half the banks are gone, the currency is gone, the public test service shut down, and a whole industry grew up next to it that does the same job differently.

Yet the iPizza protocol itself is very much alive. So it is worth rewriting: what this thing is architecturally, why it looks the way it does, and what to choose in 2026.

<!-- truncate -->

## The problem pangalink solves

I will start with a basic point, because it is non-obvious if you have not lived in this domain.

When you pay by card, money goes through the card network: merchant -> acquirer -> Visa/Mastercard -> issuing bank. That is expensive (1-3% per transaction), slow to settle (money takes days), and reversible: the customer can chargeback a month later, and the merchant is left without goods and without money.

**A bank link (Est. pangalink) takes a different path.** It is not a card. It is an ordinary account-to-account bank transfer, only pre-filled for the customer and confirmed by them in their own internet bank. The merchant receives a real SEPA transfer. Hence the properties:

- cheap (today that is a few cents per transaction, not percentages)
- money arrives immediately if the bank is on instant payments
- **irreversible**: chargeback as a concept does not exist; a transfer is a transfer

That last point explains why in Estonia, Latvia, Lithuania, Finland and Poland local bank links beat cards in online shops, while in the US and UK they barely exist. There the card infrastructure arrived earlier and took the seat.

## Architecture: the browser as transport

Here is the main idea you need to understand about iPizza. It is strange, and it is a deliberate early-2000s kind of strange.

**The merchant never talks to the bank directly.** There is no API call, no outbound HTTP request from your server to the bank, no API keys or tokens. Instead:

1. The merchant renders a normal HTML form with hidden fields
2. The fields are signed with the merchant's private RSA key
3. The form `action` points at the bank URL
4. The customer clicks the button, and **their browser** posts the form to the bank
5. The bank verifies the signature and shows a pre-filled payment
6. The customer confirms
7. The bank sends the **customer's browser** back to the merchant with a new form, now signed with the bank's key
8. The merchant verifies the bank's signature and marks the order paid

```mermaid
sequenceDiagram
    autonumber
    participant U as Customer browser
    participant S as Merchant server
    participant B as Bank

    U->>S: GET /checkout
    S-->>U: HTML with signed form<br/>(VK_SERVICE=1012, VK_AMOUNT, VK_MAC)
    Note over S,B: between merchant server and bank<br/>there is NO direct channel

    U->>B: POST form (browser, not server)
    B->>B: verify VK_MAC<br/>with merchant public key
    B-->>U: login and payment confirmation
    U->>B: confirms

    B-->>U: HTML form back to merchant<br/>(VK_SERVICE=1111, VK_MAC with bank key)
    U->>S: POST /return
    S->>S: verify VK_MAC<br/>with bank public key
    S-->>U: order paid
```

The security model is called "signed messages over an untrusted transport". The customer's browser is a mailman you do not trust, so the envelope is sealed with a cryptographic signature.

**Why this instead of a normal API?** Because the spec comes from the early 2000s. The average Estonian online shop had no static IP, no way to accept inbound connections from a bank, and no concept of OAuth. Everyone did have a browser and knew how to render a `<form>`. Banks liked it too: no API gateway to run, no merchant sessions to manage, the whole integration fits in a 10-page PDF.

There is an unexpected practical consequence I have seen many times: **on the payment page, forms for all banks are already signed and sitting side by side**. No JavaScript needed, no AJAX. Clicking the "SEB" button *is* submitting the payment. Your server does not participate between the click and the bank at all, and only learns what happened when the customer returns.

## The iPizza protocol piece by piece

All fields start with the `VK_` prefix. That is heritage from the original Hansapank specification, and current bank docs no longer spell out what the abbreviation means.

Each message is a set of fields plus a signature. The message type is given by a service number:

| Service | Direction | Meaning |
|---|---|---|
| `1011` | merchant -> bank | Payment where the merchant sets the recipient account and name |
| `1012` | merchant -> bank | Payment where the recipient account comes from the bank contract |
| `1111` | bank -> merchant | Payment succeeded |
| `1911` | bank -> merchant | Payment failed |
| `4011` / `4012` | merchant -> bank | Customer authentication request |
| `3012` / `3013` | bank -> merchant | Response with customer personal data |

In practice almost everyone uses `1012`: the merchant details are already baked into the contract, so forging the recipient is impossible even in theory. `1011` is rare, for example in agency schemes.

Main payment request fields:

| Field | Meaning |
|---|---|
| `VK_SERVICE` | service number, `1012` |
| `VK_VERSION` | signature algorithm version, `008` or `009` |
| `VK_SND_ID` | merchant ID issued by the bank |
| `VK_STAMP` | your request id, up to 20 characters |
| `VK_AMOUNT` | amount, dot as decimal separator, no thousands separator |
| `VK_CURR` | `EUR` |
| `VK_REF` | payment reference number (viitenumber) with 7-3-1 check digit |
| `VK_MSG` | text the customer sees in the bank |
| `VK_RETURN` / `VK_CANCEL` | where to return the browser after success and failure |
| `VK_DATETIME` | time in ISO 8601 with timezone |
| `VK_LANG` | `EST`, `ENG` or `RUS` |
| `VK_ENCODING` | encoding, UTF-8 by default |
| `VK_MAC` | the signature itself, base64 |

In the `1111` response you also get `VK_T_NO` (bank payment order number), `VK_SND_ACC` and `VK_SND_NAME` (payer account and name), and the important `VK_AUTO` flag.

### VK_AUTO: the only hint of server-to-server

`VK_AUTO=N` means "the customer returned to you in the browser". `VK_AUTO=Y` means "the customer never reached you, but the payment succeeded, and the bank is notifying you itself".

This matters. The customer can close the tab right after confirming the payment, and then the only thing that tells you about the payment is the bank's automatic response. The return handler **must** be idempotent: it may well receive both `VK_AUTO=N` and `VK_AUTO=Y` for the same order.

### How VK_MAC is computed

Here sits the detail that trips everyone who writes their own implementation.

The signature is not computed over "a query-string of parameters". Field values are concatenated **in a fixed order**, and before each value you put its character length formatted as a three-digit number:

```
p(x1) || x1 || p(x2) || x2 || ... || p(xn) || xn
```

where `p("EXAMPLE")` yields `007`, and an empty field yields `000`. The resulting string is hashed and signed with the private RSA key.

Why the exotic format? It protects against field-boundary substitution. If fields were simply concatenated, amount `10` with message `0 EUR` would produce the same string as amount `100` with message ` EUR`. Length prefixes make parsing unambiguous. Same idea as length-prefixed encoding in binary protocols.

Two algorithm variants:

- **`008`**: RSA + SHA-1. The historical version, still widely in production
- **`009`**: RSA + SHA-512. Arrived later, supported by banks today

If you are integrating now, take `009`. SHA-1 in new code in 2026 is a bad signal, even if a collision attack does not buy an obvious win in this specific protocol.

**Field order for signing is defined by the specification, not by form order.** Each service has its own list. That is the most common reason for "the signature does not match but everything looks correct".

### Authentication: pangalink as login

Besides payments, banks sell authentication: services `4011`/`4012` go to the bank, `3012`/`3013` return the customer's first name, last name and personal code (isikukood). This was used for logging into government services and portals when the ID-card was inconvenient.

There is a separate trap here. When verifying the response it is not enough to check the signature; you must also check:

- `VK_REC_ID` - that the message is addressed to you, not another merchant
- `VK_DATETIME` - that it is not older than about plus or minus 5 minutes
- `VK_NONCE` in `3013` - that it is a response to your specific request

Without those checks a bank-signed response can be replayed: it stays signature-valid forever and for anyone. A classic replay attack.

Amusingly, the authentication method codes (`VK_TOKEN`) show the whole history of Estonian electronic identity: 1 - ID-card, 2 - Mobiil-ID, 5 - paper one-time code card, 6 - PIN calculator, 9 - Smart-ID, 12 - biometrics.

## Where you can get cut

Since we are talking about money, here is what breaks in practice.

**Not verifying the response signature.** Fatal, and it happens regularly. The return handler is a public endpoint that receives a POST from a third party. It has no session and no CSRF token, so both protections have to be disabled. After that **the signature is the only thing between a stranger and a free order.** Anyone can send you `VK_SERVICE=1111` with someone else's order number.

**Not checking the amount.** You verified the signature but did not compare `VK_AMOUNT` to the order amount in your system. The bank signed exactly what the customer confirmed, and the customer may have confirmed a different amount if you gave them service `1011` or have another hole.

**Not checking VK_REF or VK_STAMP against your order.** Attack: take a valid signed response for your own 1-euro order and try to apply it to someone else's 1000-euro order.

**Treating arrival at the success URL as success.** A real case I saw: the bank returns the customer to the success URL, but with `VK_AMOUNT` equal to zero, because the payment did not actually go through. Trust the message contents, not which of the two URLs brought you there.

**Not being idempotent.** Because of `VK_AUTO` you will get two notifications for one payment. If each creates a payment record, you get a double payment in your reporting.

General rule: **success URL and failure URL are not the signal, they are just two routes.** The signal lives only inside the signed message.

## Certificates: the main source of pain

Now about what people ask separately: how automated is key issuance.

Short answer: **not automated at all.** At all. In 2026.

The process looks like this:

1. Sign a contract with the bank.
2. Generate an RSA pair: `openssl genrsa 2048`.
3. Make a CSR or self-signed certificate: `openssl req -new`.
4. Send the public part to the bank via internet bank or email.
5. Wait for a human at the bank to activate it. This takes from a few hours to a few days.
6. Receive the bank's certificate.
7. Put both files on the server.
8. In a few years everything expires, and nobody reminds you.

Concrete requirements from LHV: RSA at least 2048 bits, X.509 PEM format, validity no more than 10 years, self-signed certificates accepted. Coop Pank's guide recommends 4096 bits and a separate pair unrelated to the server's TLS certificate.

Two commands that make up the whole "issuance":

```bash
openssl genrsa 2048 > privkey.pem
openssl req -new -key privkey.pem -out cert-req.pem
```

Then `cert-req.pem` goes to the bank, and you wait for email.

How rare this operation is shows up well in the fact that in 2025 the Estonian E-Commerce Association recommended to its members a [Zone.ee web key generator](https://www.zone.ee/blogi/pangalingid/): a page that generates the pair in the browser. Not because openssl is hard, but because people do this once every few years and relearn the syntax each time.

Compare that to cloud providers: open the dashboard, click a button, get an `access key` and `secret key`. Thirty seconds, self-serve, any time of day, in two environments (sandbox and production). No cryptography on your side at all.

**Separately about expiry.** This is where people get burned. A certificate lives up to 10 years, so it was issued by someone who left long ago, there is no docs, no expiry monitoring, and bank emails go to a mailbox that no longer exists. One day payments in the shop simply stop working. If you inherit such an integration, **first look at the dates in the certificates** and put an alert on them.

## What changed since 2007

To avoid rewriting the old article, here is the delta.

| Then | Now |
|---|---|
| Hanza / Hansapank | Swedbank (renamed in 2008) |
| Sampo Pank -> Danske | left Estonia, portfolio went to LHV |
| Nordea with its SOLOPMT protocol | merged with DNB into Luminor, SOLOPMT is dead |
| Krediidipank | Coop Pank |
| Kroons, setup for 750-1000 kroons plus monthly fee | Euro; at LHV setup is free and about 0.05 euro per transaction |
| Only `008` (SHA-1) | There is `009` (SHA-512) |
| Public emulator pangalink.net | Service closed; [source on GitHub](https://github.com/andris9/Pangalink.net) and [third-party mirrors](https://pangalink.indoorsman.ee/) remain |
| A contract with each bank separately | One contract with an aggregator for all banks |
| No alternative | PSD2 and a whole market of providers |

Economics separately. Bank link used to be a service for those who could spare a thousand kroons a year. At LHV today it is 5 cents per transaction with no monthly fee. The entry barrier disappeared, and with it the reason to hand-write an integration with every bank.

## Three ways to accept bank payments today

This is probably the main practical takeaway.

```mermaid
flowchart TB
    subgraph A["A. Direct iPizza"]
        A1["Merchant"] -->|VK_MAC with own key| A2["Swedbank"]
        A1 -->|VK_MAC with own key| A3["SEB"]
        A1 -->|VK_MAC with own key| A4["LHV"]
    end

    subgraph B["B. Bank-link aggregator"]
        B1["Merchant"] -->|one iPizza contract| B2["LHV / Maksekeskus / EveryPay"]
        B2 --> B3["all banks"]
    end

    subgraph C["C. PSD2 aggregator"]
        C1["Merchant"] -->|REST + JWT| C2["Montonio"]
        C2 -->|Open Banking API| C3["all EU banks"]
    end

    style A fill:#f8d7da,stroke:#dc3545
    style B fill:#fff3cd,stroke:#ffc107
    style C fill:#d4edda,stroke:#28a745
```

### A. Direct iPizza with each bank

You sign a contract with each bank separately, generate keys for each, store certificates for each, and maintain your own protocol implementation.

Pros: minimal fees, money arrives on your account, no middleman in the chain.

Cons: N contracts, N certificates with N expiry dates, your code owns payment cryptography, all for a protocol nobody develops further.

When it makes sense: large volume where fractions of a cent turn into real money, and a team that maintains it. Or a legacy integration that works and is left alone.

### B. Aggregator on top of bank links

You sign one contract and get buttons for all banks. LHV sells this explicitly as "one agreement = all bank links": LHV, Swedbank, SEB, Luminor, Coop, Citadele, Artea, Revolut, across the Baltics. Maksekeskus (MakeCommerce brand) and EveryPay play a similar role.

Technically some of them still use bank links under the hood, but you do not see that.

The key developer difference: **Maksekeskus and EveryPay authenticate with an identifier-plus-secret pair, not certificates.** The whole openssl-and-email-to-the-bank story disappears.

### C. PSD2 aggregator

This is architecturally a different thing, and worth looking at separately.

The PSD2 directive forced all European banks to open APIs through which a licensed provider can initiate a payment on behalf of the customer. That provider is a PISP (Payment Initiation Service Provider). Banks must let them in because it is law, not a commercial deal.

Montonio works this way. They hold a payment institution licence (Lithuania, No. 51 from 2020, with passport rights into Estonia), they are registered as a PISP, and their payment method in the API is literally called `paymentInitiation`.

What that gives versus iPizza:

- **Coverage is not limited to Estonian banks.** PSD2 works across the EU, so Revolut, Wise, N26 and Polish banks show up in the list
- **No bank contracts at all.** Not with a single bank. Contract only with the provider
- **A normal REST API**, not HTML forms

Integration architecture with Montonio looks fundamentally different:

```mermaid
sequenceDiagram
    autonumber
    participant U as Browser
    participant S as Merchant server
    participant M as Montonio
    participant B as Bank

    S->>M: POST /api/orders<br/>body = JWT signed HS256 with shared secret
    M-->>S: paymentUrl
    S-->>U: redirect to paymentUrl
    U->>M: bank selection page
    M->>B: payment initiation via PSD2 API
    U->>B: confirmation in the bank
    B-->>M: result

    par customer return
        M-->>U: redirect to returnUrl?order-token=JWT
        U->>S: GET returnUrl
    and webhook
        M->>S: POST notificationUrl<br/>{ orderToken: JWT }
    end

    S->>S: verify JWT signature<br/>and paymentStatus == PAID
```

Note the symmetry with iPizza despite the completely different shape:

| | iPizza | Montonio |
|---|---|---|
| Initiation | HTML form via the browser | server-to-server POST |
| Message format | flat `VK_*` fields | JWT |
| Signature | RSA, asymmetric | HS256, shared secret |
| Keys | certificates from the bank by hand | access key and secret in the dashboard |
| Notify without the customer | `VK_AUTO=Y` on the same URL | separate webhook on `notificationUrl` |
| What to verify | `VK_MAC`, amount, `VK_REF` | JWT signature, `paymentStatus`, `merchantReference` |

The main conceptual shift: **symmetric signature instead of asymmetric.** In iPizza the bank does not know your private key, so a message you signed proves authorship. In HS256 JWT both sides know one secret, so it only proves the message came from someone who knows the secret. For a "we already trust each other by contract" setup that is enough, but you must guard the secret like a private key.

Second shift: **webhook on a separate channel instead of a flag in a shared message.** That is cleaner: you have two endpoints with different semantics, not one endpoint that must guess from a flag who called it.

## Libraries by language

Here I have to say an unpleasant truth, but first a clarification on specific links people asked me about.

**`Shmarkus/Banklink` is PHP, not Node.js.** A small library, about 5 stars, last activity in 2024. If you need PHP, prefer `renekorss/Banklink`: it is healthier, 36 stars, updated in 2025, supports both old and new iPizza, plus Nets Estonia gateways and others.

**`Voog/ipizza` is Ruby**, and that is a good choice. Updated in 2025, it is the de facto standard for Rails projects in Estonia.

Summary:

| Language | What exists | Status | Verdict |
|---|---|---|---|
| PHP | [renekorss/Banklink](https://github.com/renekorss/Banklink) | alive, updated 2025 | take this |
| PHP | [Shmarkus/Banklink](https://github.com/Shmarkus/Banklink) | 2024, small | alternative |
| Ruby | [Voog/ipizza](https://github.com/Voog/ipizza) | alive, updated 2025 | take it |
| Java | [nortal/banklink](https://github.com/nortal/banklink) | last commit 2021 | use as reference |
| Node.js | `tonistiigi/ipizza` (npm `ipizza`) | last publish 2014 | **dead** |
| Go | nothing | - | **none at all** |

For Node.js and Go there are no living iPizza libraries. Not "there are abandoned ones" - none. The only npm hits for banklink are unrelated projects or a 2014 package.

**What to do.** Two options, both fine.

*Option one: write it yourself.* It sounds scarier than it is. The whole protocol is about 150 lines:

- build a dict of `VK_*` fields
- concatenate values in spec order with three-digit length prefixes
- `crypto.sign` / `rsa.SignPKCS1v15` that string
- render the form
- on return, repeat the concatenation and `crypto.verify` with the bank public key

No HTTP clients, no state, no dependencies beyond standard crypto. In Go that is `crypto/rsa` and `crypto/sha512`, in Node the built-in `crypto`. Honestly, for iPizza a library is almost unnecessary: it saves you the field-order table, not complex logic.

Always take field order from the current specification, not from someone else's code. [LHV publishes it openly](https://partners.lhv.ee/et/banklink/), that is the most convenient source.

*Option two, and for new Node or Go projects it is the more honest one:* do not write iPizza at all. Take Montonio or another aggregator where integration is a JSON `POST` and JWT verification, for which libraries exist in every language. You lose a few cents per transaction and gain no cryptography in your code and no certificates in your life.

My practical advice: **if you are choosing from scratch and you do not have million-scale volume, do not write iPizza.** The only reason to deal with `VK_MAC` today is a legacy integration you cannot turn off.

## How to test it

There used to be a public [pangalink.net](https://github.com/andris9/Pangalink.net) - an emulator of all Estonian banks where you could run a full cycle without a contract. The service closed, but the sources remain and run locally, and also [live in third-party mirrors](https://pangalink.indoorsman.ee/). For debugging your own signature implementation it is still the best tool: it shows exactly which string it expected before hashing, and that is precisely where everything breaks.

Cloud providers make this easier: Montonio has a full sandbox with a separate key set, available right after registration, before any business approval. Webhooks are tested locally via ngrok.

## What to choose

Short version, by situation.

**New shop on a ready platform** (WooCommerce, Magento, PrestaShop, Shopify): take a ready aggregator plugin. Montonio, LHV and Maksekeskus all ship plugins for popular platforms. You write no code at all.

**New project on your own code, Node or Go**: Montonio or another aggregator with a REST API. Writing iPizza with no living libraries is volunteering to maintain cryptography to save cents.

**Need more than Estonian banks** (Poland, Finland, international neobanks): only a PSD2 provider. iPizza is a local protocol; outside the Baltics it does not exist.

**Large volume, you have a team, every fraction of a percent matters**: direct iPizza or a contract with a bank aggregator like LHV. But set up certificate expiry monitoring before you need it.

**Legacy working integration**: leave it alone, but check three things. That the response signature is actually verified. That the amount is checked against the order. That you know when the certificates expire.

## Links

- [LHV bank link technical specification](https://partners.lhv.ee/et/banklink/) - the most accessible primary source, with tables for all services and MAC008/MAC009
- [LHV bank link](https://www.lhv.ee/en/bank-link) - aggregator terms
- [Bank Link SEB](https://www.seb.ee/en/business/daily-banking/bank-link)
- [Montonio docs](https://docs.montonio.com/) - a good example of what a normal payment API looks like today
- [Pangalink.net on GitHub](https://github.com/andris9/Pangalink.net) - emulator for local debugging
- [Zone.ee key generator](https://www.zone.ee/blogi/pangalingid/)
- [My old 2007 article](/ru/blog/tech/integration/pangalink/) - how it looked in the era of kroons and Nordea
