Pangalink today (2026): iPizza architecture and cloud alternatives
Eighteen years ago I wrote about 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.
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:
- The merchant renders a normal HTML form with hidden fields
- The fields are signed with the merchant's private RSA key
- The form
actionpoints at the bank URL - The customer clicks the button, and their browser posts the form to the bank
- The bank verifies the signature and shows a pre-filled payment
- The customer confirms
- The bank sends the customer's browser back to the merchant with a new form, now signed with the bank's key
- The merchant verifies the bank's signature and marks the order paid
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 production009: 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 merchantVK_DATETIME- that it is not older than about plus or minus 5 minutesVK_NONCEin3013- 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:
- Sign a contract with the bank.
- Generate an RSA pair:
openssl genrsa 2048. - Make a CSR or self-signed certificate:
openssl req -new. - Send the public part to the bank via internet bank or email.
- Wait for a human at the bank to activate it. This takes from a few hours to a few days.
- Receive the bank's certificate.
- Put both files on the server.
- 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":
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: 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 and third-party mirrors 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.
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:
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 | alive, updated 2025 | take this |
| PHP | Shmarkus/Banklink | 2024, small | alternative |
| Ruby | Voog/ipizza | alive, updated 2025 | take it |
| Java | 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.SignPKCS1v15that string- render the form
- on return, repeat the concatenation and
crypto.verifywith 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, 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 - 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. 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 - the most accessible primary source, with tables for all services and MAC008/MAC009
- LHV bank link - aggregator terms
- Bank Link SEB
- Montonio docs - a good example of what a normal payment API looks like today
- Pangalink.net on GitHub - emulator for local debugging
- Zone.ee key generator
- My old 2007 article - how it looked in the era of kroons and Nordea