Pangalink today (2026): iPizza architecture and cloud alternatives

Pangalink today (2026): the browser as an untrusted courier between shop and bank

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.

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

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

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.

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