Shipping ArmPay: How we reverse-engineered Ameria's VPOS
8 min readBy Tigran
The problem we started with
Armenia is a country of roughly three million people, a meaningful slice of which already buys online. And yet, the day we started ArmPay, no merchant could stand up a Shopify store and accept a local ArCa card at checkout through a native payment app. The ones who did accept local cards were sending customers to a third-party page, breaking the Shopify checkout flow, losing the analytics, and usually absorbing a worse FX rate.
The opportunity was not technical novelty. Virtual POS integrations have existed for twenty years. The opportunity was that no one had done the boring work of turning one of the Armenian banks' VPOS offerings into a Shopify Payments API integration, shipping a public app, passing App Store review, and maintaining it. That is the work we took on.
First look at the bank's documentation
Ameria Bank has a VPOS product, a developer portal, and a PDF. The PDF is the canonical spec. If you have ever integrated with a Soviet-era financial institution you know the genre: fields documented in a table, example requests in a monospaced block, a short paragraph about 3D Secure that gestures at the EMVCo standard without quite committing to a version.
What was missing, or unclear:
- Response codes. A handful of successful scenarios were enumerated. Error codes were mentioned in passing, but the mapping between the bank's internal codes and what the merchant should actually show the buyer was left as an exercise for the reader.
- 3DS challenge handling. The document described the happy path — frictionless authentication — clearly enough. The challenge path, where the cardholder is bounced to their issuing bank for a one-time code, was described in two sentences, and the way the bank's iframe communicated the final result back to the parent window was not specified at all.
- Session lifecycle. VPOS sessions have a TTL. What the TTL was, what happens when it expires in the middle of a 3DS challenge, what the bank's response looks like in that case — none of it was written down. We learned all of it by triggering the edge cases on purpose.
- Refund semantics. Full refunds were documented. Partial refunds were documented by implication — "the same endpoint with a smaller amount" — but the rules around refund windows, whether you could partial-refund a partially-captured auth, and what codes the bank returned for "refund requested but the original transaction is older than the refund window" were not.
None of this was malice. Bank documentation is written for bank integrators, and bank integrators call the bank. We did not want to call the bank every time we shipped a change, so we had to build our own map.
How we tested without real transactions
The first rule of building a payment integration: you cannot use real money to learn. You need a sandbox that mirrors production behavior, and you need to stimulate it aggressively.
Ameria's test environment accepts a small set of dummy card numbers, each with predictable behavior. One card always succeeds. One always fails with a generic decline. One triggers a 3DS challenge every time. One returns a "do not honor" on capture even after a successful auth. These are the levers you have.
Our process looked like this:
- A minimal POC before Shopify. Before we wrote a single line against the Shopify Payments API, we built a twenty-line Node script that initiated a VPOS session, walked through the redirect, and logged every field in every callback. No framework, no UI, just
fetchandconsole.log. The goal was to understand the bank's wire protocol in isolation. - Capture every callback. We pointed the bank's webhook URLs at an
ngroktunnel into a local Express app that wrote every incoming request to a JSON file on disk. Over a couple of weeks we accumulated hundreds of captured callbacks — successes, failures, 3DS challenges, expired sessions, accidental double-submits. This corpus became our unit-test fixture set. - Replay and diff. Once we started integrating with Shopify, every time the bank changed something — a new header, a renamed field, a subtly different error code — we caught it by diffing the new capture against the corpus. This caught at least three silent behavior changes over the first year that we would otherwise have noticed only in production.
- Deliberate destruction. We built a harness that triggered each edge case on purpose. Expire a session mid-challenge. Submit a refund after the window. Attempt a capture on an auth that was already voided. The harness became part of CI.
The POC-first discipline paid for itself within a month. When the time came to wire everything up behind the Shopify Payments API, we were not learning the bank and learning Shopify at the same time.
Shopify App Store review: three gotchas
The Shopify App Store review queue is not a rubber stamp, especially for payment apps. Three things caught us on the first submission.
GDPR webhooks. Shopify requires every public app to respond to three mandatory GDPR webhooks: customers/data_request, customers/redact, and shop/redact. They are documented clearly enough, but the review robot checks the signature verification path specifically — if your endpoint returns 200 OK to a request with a bad HMAC, you fail. We had initially implemented signature verification only on the order webhooks, and left the GDPR handlers as stubs that returned 200. Reviewer caught it on day one. Fix was an hour; the lesson was that reviewers verify the negative case.
Refund flow completeness. Our first submission supported refunds. It did not support the UI affordance for a merchant to refund from the order page. In Shopify's model, a payment app needs to honor refund requests that originate from the admin UI, not just from our own dashboard. We had assumed the bank integration was the hard part; the Shopify-side plumbing to round-trip a refund initiated from the order timeline was actually what was missing. Fix took three days because we had to revisit how we tracked partial captures.
Error-path UX. The reviewer tested the failure path by submitting an order with a card that declined. Our integration correctly returned a payment_failed state. What we had not done was provide a buyer-facing error message more specific than "Payment failed." The reviewer flagged this as confusing — the buyer does not know whether to retry with the same card, try a different card, or contact their bank. We added a mapping from the bank's error codes to three user-facing categories (retry, try another card, contact bank) and the next submission passed.
What we'd do differently
Looking at ArmPay after a year of production traffic, three things stand out.
Start with an abstraction layer, not a direct client. Our first version of the bank client was Ameria-specific. That was fine until the second bank's VPOS integration landed, at which point we had to retrofit a provider abstraction that should have been there from day one. If you know in advance that you will support more than one provider, spend the two days to write the interface first.
Treat the bank's response codes as a separate bounded context. We initially let bank error codes leak through into our Shopify-facing error handling. That meant every time the bank added a code, we had a small incident. A dedicated translation layer — bank codes in, stable EcomHub codes out — would have cost a day to write and would have saved a dozen small changes later.
Invest earlier in the replay harness. We built the callback-replay harness three months into production, after the second silent wire-format change. Building it in the first month would have caught both earlier. It is the single most valuable piece of internal tooling we have, and it was the easiest to defer.
If you are building your own bank integration
Three things to get right from the first week.
- Capture everything, forever. Every request, every response, every webhook callback. Store it on disk, keep it for at least a year. You will need it.
- Build a replay-and-diff pipeline before you need it. When the bank changes something, you want to know in minutes, not weeks. The pipeline does not need to be fancy — a daily cron job that replays a sample of yesterday's callbacks against today's code and emails you the diff is enough.
- Separate bank-speak from app-speak with a hard translation boundary. Bank codes, bank field names, bank URL structures stay inside the provider package. Everything that leaves the provider package is in your own stable vocabulary. Breaking this rule once is the difference between a contained incident and a cross-cutting change.
The ArmPay work is not glamorous. It is the plumbing of a small national payment ecosystem being dragged onto a platform that expects first-class integrations. What we built is not clever, but it does work, and the merchants who use it do not think about it. That is the bar.