MyPaydocs

DocumentationIntegration guide

MyPay Checkout

Create a payment from your server, send the shopper to the MyPay checkout page and receive the signed result on your webhook.

Quick start

Two ways to integrate

  • A shop plugin. MyPay ships plugins for WooCommerce and OpenCart. Install the plugin, enter the Service URL, your checkout key, your subsystem ID and your webhook signing secret in its settings, and it does everything on this page for you — see Shop plugins.
  • Your own backend. Any language, any framework. Your server creates the payment, sends the shopper to the MyPay checkout page and receives the signed result on your webhook. The rest of this guide is for you.

The flow

  1. Your server creates a payment with POST /api/webhooks/transaction and gets back a redirectUrl.
  2. You send the shopper there. They pay on the MyPay checkout page and come back to your successUrl or cancelUrl.
  3. MyPay posts a signed event to your webhookUrl. You verify the signature, check the amount against your order and mark it paid.
  4. If you ever miss a webhook, read the payment with GET /api/webhooks/transaction/{merchantOrderId}.

Base URL

EnvironmentSERVICE_URL
Sandbox (test cards, test keys)https://sandbox.mypay-checkout.com
Productionhttps://web.mypay-checkout.com

Every endpoint below is under {SERVICE_URL}/api. In the WooCommerce and OpenCart plugins, SERVICE_URL is the Service URL setting. Keys and webhook secrets belong to one environment and don't work in the other.

Prerequisites

You need up to three values per virtual terminal. MyPay creates the checkout key and the webhook secret when it sets up your terminal. You can create or replace any of them yourself on the terminal's page in the MyPay Payments dashboard (in Wallet's E-commerce section, or at {SERVICE_URL}/dashboard), under Integration credentials (Κλειδιά σύνδεσης in Greek). Each is shown once, when it is created, and is never readable again: copy it then.

ValueLooks likeUsed for
Checkout keympc_…Creating payments. This is the key that lives in your shop plugin.
Full access keympf_…Creating payments, and refunds and cancellations. Optional: create one only if your own systems refund.
Webhook signing secretwhsec_…Verifying that a webhook really came from MyPay.

You also need a webhook URL on your server that can receive payment results, over HTTPS.

Every call also carries a subsystem ID, as a non-empty string. Send mypay. MyPay does not authenticate with it, but it copies the value into the customer's redirectUrl, so never put anything secret in it.

Data formats

Every request and response body is JSON (Content-Type: application/json, UTF-8). Field names are camelCase as shown; the one exception is the webhook envelope, whose top-level names are snake_case (event_id, created_at). These formats apply everywhere:

KindFormatExamples
AmountJSON integer in cents (1/100 of the currency unit), as in Stripe. Covers amount and each item's price on create, amount on refund, amount and refundedAmount in what we send back. A string ("1050"), a decimal (10.5) or anything below 1 is refused with 400.1050 = 10.50 EUR · 1000 = 10.00 EUR · 0 is allowed only for a free item
CurrencyThree upper-case letters, ISO 4217. Currencies whose minor unit is not a hundredth (JPY, BHD, …) are refused.EUR
Order numbermerchantOrderId: a non-empty JSON string, unique per order in your shop. It is the payment's identity — the same number always means the same payment."ORDER-123456", "1001" — not 1001
URLsuccessUrl, cancelUrl, webhookUrl: absolute https:// URLs without user name or password. The exact final URL — MyPay follows no redirect when it calls your webhook.https://yoursite.com/webhook
IdentifierstransactionId is MyPay's UUID for the payment; event_id is evt_ followed by 32 hex characters, identical across retries of the same event.a3f91b2c-… · evt_9f2c41a8…
TimeUnix seconds, as an integer: created_at in the webhook body and t in the Mypay-Signature header.1757600000
Empty valuesInside customer, an empty string or null counts as "not sent". A field not in this guide is refused with 400."line2": null
ErrorsEvery failure is { "success": false, "message": "…" } with an HTTP status. A 400 from a validation rule names the field; top-level names come back in snake_case (success_url, eshop_order_id, eshop_customer_email), fields inside customer as you sent them.customer.billingAddress.country must be …

Strings are refused on purpose. An integration written for the older service sends "10.00", and it should fail loudly rather than be read as ten cents. If your shop still points at the old Service URL, read Shop plugins before you change anything.

Testing

The sandbox at https://sandbox.mypay-checkout.com has its own checkout key, subsystem ID and webhook secret. Payments made there move no money, and your webhook receives the same signed events as in production.

Test card

On the sandbox checkout page, pay with:

FieldValue
Card number4111 1111 1111 1111
ExpiryAny date in the future, e.g. 12/30
CVVAny three digits, e.g. 123
NameAnything

A successful test payment sends payment.succeeded to your webhookUrl and the order shows as PAID on Retrieve transaction. Real card numbers are refused on the sandbox, and test cards are refused in production.

Never put the production key in a test shop, or the sandbox key in a live one. The prefixes look the same in both environments — keep the two sets of values apart in your configuration.

Authentication

Every /webhooks endpoint requires two things:

  1. Your API key as a Bearer token.
  2. Your subsystem ID in the request body — or, for the one GET endpoint, in the query string as ?subsystemId=….
HTTP
POST /api/webhooks/transaction
Content-Type: application/json
Authorization: Bearer mpc_YOUR_CHECKOUT_KEY
JSON
{ "subsystemId": "YOUR_SUBSYSTEM_ID", "...": "..." }

A request without a key gets 401. A request without subsystemId (or subsystem_id), or with a number instead of a string, gets 400 Missing subsystemId. This applies to refund and cancel too.

Webhooks we send you are not authenticated with your API key. They are signed — see Webhook. Nothing MyPay sends you ever contains your key, and you should never accept a webhook on the basis of a bearer token.

API keys and what they can do

There are two roles, and the key tells you which one it is by its prefix:

PrefixRolePOST /transactionGET /transaction/…POST /refund/…POST /cancel/…
mpc_Checkout✓✓✕ 403✕ 403
mpf_Full access✓✓✓✓

The refusal is:

JSON
{ "success": false, "message": "This API key is not permitted to perform this action." }

Use the checkout key everywhere you can. It is the key that ends up in a plugin's settings, in a .env file, in version control, and in the hands of every developer the shop has ever hired. A leaked checkout key lets someone create payments; a leaked full access key lets them send your money back out.

You can tell a key's role without calling us. The role is part of the key and is covered by the hash we store, so mpf_… can never resolve as a checkout key or the reverse. Read the prefix — that is exactly what our own plugins do to decide whether to offer a refund button.

Both keys can be replaced from the same Integration credentials panel. After a replacement the previous key keeps working for 7 days, so you can update your servers without downtime. Replacing the same key again within those 7 days ends the first key at once. A webhook secret's grace period is 24 hours. Delete on the full access key removes it immediately, with no grace period.

API endpoints

1. Create transaction

POST/webhooks/transaction

Key required: checkout or full access

Initialize a new payment transaction.

Request body

JSON
{
  "subsystemId": "YOUR_SUBSYSTEM_ID",
  "amount": 1050,
  "currency": "EUR",
  "successUrl": "https://yoursite.com/success",
  "cancelUrl": "https://yoursite.com/cancel",
  "merchantOrderId": "ORDER-123456",
  "webhookUrl": "https://yoursite.com/webhook",
  "orderItems": [
    { "name": "Product Name", "quantity": 1, "price": 1050 }
  ],
  "customer": {
    "email": "maria@example.com",
    "firstName": "Maria",
    "lastName": "Papadopoulou",
    "phone": "+30 691 234 5678",
    "billingAddress": { "line1": "Ermou 10", "line2": null, "city": "Athens", "postalCode": "10563", "country": "GR" },
    "shippingAddress": { "line1": "Tsimiski 5", "city": "Thessaloniki", "postalCode": "54624", "country": "GR" }
  }
}

Replace example values and credentials before use.

FieldType · formatRequiredRule
subsystemIdstringrequiredNon-empty. Send mypay. Copied into the customer's redirectUrl — nothing secret.
amountinteger · centsrequiredAt least 1. The total the customer pays, including items and delivery.
currencystring · ISO 4217requiredThree upper-case letters, e.g. EUR. Hundredth-minor-unit currencies only.
merchantOrderIdstringrequiredNon-empty: "1001", not 1001. Your order number; the same one always returns the same payment.
successUrlstring · https URLrequiredWhere the customer lands after paying. No user name or password in it.
cancelUrlstring · https URLrequiredWhere the customer lands after cancelling.
webhookUrlstring · https URLrequiredWhere MyPay posts the signed result. The exact final URL — no redirects are followed.
customerobjectoptionalThe customer's email, name, phone and addresses — see Customer details below.
customerEmailstring · emailoptionalThe older way to send the email, still accepted. If you also send customer.email, the two must be the same.
orderItemsarray of objectsoptionalDisplay only on the payment page. Each item is { name, quantity, price }.
orderItems[].namestringrequiredShown on the payment page.
orderItems[].quantityintegerrequiredPieces of this item.
orderItems[].priceinteger · centsrequiredUnit price, shown as such. 0 is allowed for a free item.

Customer details

Everything in customer is optional. Send what your shop already has, so the customer doesn't type it again:

FieldType · formatRequiredRule
customer.emailstring · emailoptionalA valid email, at most 254 characters.
customer.firstNamestringoptionalAt most 127 characters.
customer.lastNamestringoptionalAt most 127 characters.
customer.phonestringoptionalDigits, spaces and + - ( ), at most 20 characters, at least 6 digits.
customer.billingAddressobjectoptional{ line1, line2, city, postalCode, country } — any part may be left out.
customer.shippingAddressobjectoptionalSame shape as billingAddress.
….line1stringoptionalAt most 127 characters.
….line2stringoptionalFlat or floor, at most 25 characters (the bank's limit).
….citystringoptionalAt most 127 characters.
….postalCodestringoptionalLetters, digits, spaces, dashes; at most 16 characters.
….countrystring · ISO 3166-1 alpha-2optionalTwo upper-case letters, e.g. GR.

An empty string counts as not sent. A malformed value, or a field not in this table, is refused with 400 and a message that names the field:

JSON
{ "success": false, "message": "customer.billingAddress.country must be a two-letter ISO 3166 country code, e.g. GR" }

What the payment page does with them:

  • Email: the customer's bank needs it to confirm a card payment. If you don't send it, the page asks for it before any payment method can be used. If you do, the page shows no field at all.
  • Name: pre-fills the name on the card. The customer can change it, because the card may be someone else's.
  • Phone and addresses: never asked on the page. They go to the bank with the card's 3-D Secure check, which helps the bank approve without an extra step.

Response

JSON
{ "success": true, "redirectUrl": "https://web.mypay-checkout.com?transactionId=abc123&subsystem_id=YOUR_SUBSYSTEM_ID" }
FieldType · formatMeaning
successbooleantrue when a payment exists for this order number — created now or found from an earlier call.
redirectUrlstring · https URLWhere to send the customer, as it is. On the host of the environment you called (sandbox or production); the query carries transactionId and your subsystem_id.
messagestringOnly on failure: why, for a person to read.

A payment can be paid for 30 minutes after it is created, and allows 5 declined attempts. After that it is expired.

merchantOrderId identifies the payment: sending the same one twice returns the existing payment rather than creating a second one, so a retried request is safe. The repeat doesn't change the URLs or the items, and restarts the 30-minute window if it has lapsed. While the payment is unpaid, the email and customer fields you send replace the stored ones, so a customer who went back and fixed their email is handled.

That only holds while the price agrees. If a payment already exists for that order number with a different amount or currency, the request is refused with 409:

JSON
{ "success": false, "message": "Order 'ORDER-123456' already has a payment for 10.5 EUR; refusing to serve it as 12 EUR." }

The message is for people, so its amounts are in euros, not cents, and printed as stored (10.5, 10): this is a create with "amount": 1200 against an earlier one with 1050. Read it as: something already opened a payment for this order at another price. Investigate it — do not retry with a new order number.

Errors

StatusMessageMeaning
400amount must be a positive integer in cents, e.g. 1050 for 10.50A string, a decimal or zero was sent as an amount.
400success_url: …, eshop_order_id: …, customer.phone must be …A field broke its rule; the message names it.
401UnauthorizedNo key, or a key of the other environment.
403virtual terminal … is not active / Merchant is not active for Payments.The terminal or the merchant is switched off at MyPay. Retrying won't help; contact MyPay.
409Order '…' already has a payment for … EUR; refusing to serve it as … EUR.Same order number, other amount or currency. Investigate; do not retry with a new number.
400Internal Server ErrorA failure on our side, not in your request. Retry with the same merchantOrderId.

2. Retrieve transaction

GET/webhooks/transaction/{merchantOrderId}?subsystemId=YOUR_SUBSYSTEM_ID

Key required: checkout or full access

Read the current state of a payment. Read-only — it never changes anything, which is why the checkout key may call it.

Request

FieldType · formatRequiredRule
merchantOrderIdstring · in the pathrequiredYour order number, URL-encoded.
subsystemIdstring · in the queryrequiredThe same value as on create.

Response

JSON
{
  "success": true,
  "transaction": {
    "merchantOrderId": "ORDER-123456",
    "transactionId": "a3f91b2c-...",
    "status": "PARTIALLY_REFUNDED",
    "expired": false,
    "amount": 1050,
    "refundedAmount": 525,
    "currency": "EUR",
    "customer": {
      "email": "maria@example.com",
      "firstName": "Maria",
      "lastName": "Papadopoulou",
      "phone": null,
      "billingAddress": { "line1": "Ermou 10", "line2": null, "city": "Athens", "postalCode": "10563", "country": "GR" },
      "shippingAddress": null
    }
  }
}
FieldType · formatMeaning
transaction.merchantOrderIdstringYour order number.
transaction.transactionIdstring · UUIDMyPay's identifier of the payment. The same value arrives in the webhook.
transaction.statusstring · enumOne of the statuses below. Treat any other value as not paid.
transaction.expiredbooleantrue only for an INITIATED payment whose checkout link has lapsed.
transaction.amountinteger · centsWhat the payment was created for.
transaction.refundedAmountinteger · centsHow much has been returned so far; 0 when nothing.
transaction.currencystring · ISO 4217As created.
transaction.customerobjectEvery field of Customer details, null where unknown. email is the one you sent, or the one the customer typed on the page.
statusMeaning
INITIATEDCreated, not paid yet. A declined card leaves it here — the customer can try again.
PAIDThe money was taken.
PARTIALLY_REFUNDEDPaid, and part of it has been returned. refundedAmount says how much.
REFUNDEDPaid, and all of it has been returned.
CANCELLEDPaid, then voided. Nothing was charged in the end.

expired is true only for an INITIATED payment whose checkout link has lapsed. If the customer comes back, call Create transaction again with the same order number and amount: that reopens the same payment, at the same redirectUrl, with a new 30-minute window. The exception is a payment whose attempts are used up after repeated declines: it stays expired and can no longer be paid online. Do not switch to a new order number — a wallet payment that was still settling can complete the old one after the link lapsed, and you would be paid twice.

Only PAID and PARTIALLY_REFUNDED mean money is held for the order. Treat any status not in this table as not paid — new ones may be added.

Errors

StatusMessageMeaning
404Transaction not foundNo payment with that order number on this terminal — or it belongs to another terminal.
502Failed to load transactionThe lookup failed. Nothing changed; retry later. Also what you get while the terminal or merchant is switched off — check with MyPay if it persists.

Use it to reconcile, not to replace the webhook. The webhook tells you the moment a payment finishes; this endpoint is for when you missed one, or before you act on an order you are unsure about. Unlike a search, it reads the payment directly, so it is accurate immediately after a create or a refund. Polling it in a loop while the customer is on the payment page is not needed.

3. Cancel transaction

POST/webhooks/cancel/{merchantOrderId}

Key required: full access (mpf_)

Void a paid transaction and return the full amount immediately.

Request body

JSON
{ "subsystemId": "YOUR_SUBSYSTEM_ID" }

Replace example values and credentials before use.

FieldType · formatRequiredRule
merchantOrderIdstring · in the pathrequiredYour order number, URL-encoded.
subsystemIdstringrequiredThe same value as on create. The only body field.

Response

JSON
{ "success": true, "message": "Transaction cancelled successfully" }

Only a payment in PAID, with nothing refunded yet, can be cancelled. Anything else, PARTIALLY_REFUNDED included, gets 404 "Valid transaction not found".

Not every acquiring provider supports this. Some have no usable reversal, and the call is refused before it reaches the bank:

JSON
{ "success": false, "message": "The underlying virtual terminal provider does not support cancelling transactions. Refund the payment instead." }

Card refunds still work on those terminals. If your terminal is on such a provider (MyPay tells you when it sets up the terminal), do not build a cancel path — build a full refund. On the others, a cancel is a void at the bank, which the bank may refuse once the payment has settled; refund it then.

4. Refund transaction

POST/webhooks/refund/{merchantOrderId}

Key required: full access (mpf_)

Full or partial refund of a transaction in status PAID or PARTIALLY_REFUNDED.

Request

HTTP
POST /api/webhooks/refund/ORDER-123456
Content-Type: application/json
Authorization: Bearer mpf_YOUR_FULL_ACCESS_KEY
Idempotency-Key: ORDER-123456-0
JSON
{
  "subsystemId": "YOUR_SUBSYSTEM_ID",
  "amount": 525,
  "description": "Damaged item"
}

Replace example values and credentials before use.

FieldType · formatRequiredRule
merchantOrderIdstring · in the pathrequiredYour order number, URL-encoded.
Idempotency-KeyHTTP header · stringoptional1 to 255 characters. Optional, and strongly recommended: your name for this refund — see Retrying a refund.
subsystemIdstringrequiredThe same value as on create.
amountinteger · centsoptionalPositive. Omit it for a full refund of the remaining balance. Cannot exceed the remaining refundable balance.
descriptionstringoptionalAt most 25 characters.

Response

JSON
{ "success": true, "message": "Refund processed successfully" }
StatusMeaningWhat to do
200Refunded.Record it.
400Refused, by the rules above or by the bank. Nothing was refunded.Fix the request, or refund another way.
409The bank did not confirm this refund in time, or another refund on this payment is still unconfirmed. The money may have moved. We are checking with the bank.Don't record it, and don't send it as a new refund. Retry later with the same Idempotency-Key.
422This Idempotency-Key was already used for a refund of another amount.Retry with the first amount, or use a new key for a new refund.

We wait up to 30 seconds for the bank, so give the call a longer timeout, such as 60 seconds. Concurrent refunds on the same order are serialised, so two partial refunds cannot both pass the remaining-balance check.

Retrying a refund

A refund changes the money at the bank and our record of it, and the two can't change at the same instant. After a timeout or a dropped connection you can't tell whether the refund happened. Send an Idempotency-Key that names the refund, and send the same key when you retry:

  • A retry with the same key gets the first request's answer. It never refunds twice, even once the payment is fully refunded.
  • A key whose refund was refused (400) is free again. Nothing moved, so you can retry with it.
  • Choose a key that stays the same when someone clicks Refund again after an error, such as your order number plus the amount you had already refunded before this one. A new random key per click does not protect against that second click.

While a refund's outcome is unknown, we answer 409 to every other refund and cancel on that payment, with or without a key. Most are settled within a few minutes. Some need a manual check at the bank, which takes longer.

A payment made with IRIS cannot be refunded through the API, because IRIS has no refund at the bank. The call gets 400 saying so; return the money by bank transfer.

Errors from refund and cancel

StatusMessageMeaning
400Virtual terminal transaction with order id '…' not found.No payment with that order number on this terminal.
404Transaction not foundThe payment is not in PAID or PARTIALLY_REFUNDED.
404Valid transaction not foundCancel on a payment that is not PAID.
400Refund amount cannot exceed original transaction amountMore than what is left to refund.
400Description must be 25 characters or less
400The bank's own messageThe bank refused; nothing changed.
403This API key is not permitted to perform this action.A checkout key (mpc_) was used.

No webhook is sent for refunds or cancellations, whether made through the API or in the MyPay dashboard. Update the order on your side when the call succeeds.

Webhook

When a payment finishes, MyPay posts the result to your webhookUrl.

Headers

HTTP
Content-Type: application/json
Mypay-Signature: t=1757600000,v1=5257a869e7...

There is no Authorization header. The signature is the proof.

FieldType · formatMeaning
Content-TypeHTTP headerAlways application/json.
Mypay-SignatureHTTP header · t=<unix seconds>,v1=<hex>t is when we signed; v1 is HMAC-SHA256 of <t>.<raw body> with your webhook secret, in lower-case hex. During a secret rotation two v1 parts are present.

Payload

JSON
{
  "event_id": "evt_9f2c41a8b3d04e7f8a1b2c3d4e5f6071",
  "type": "payment.succeeded",
  "created_at": 1757600000,
  "data": {
    "merchantOrderId": "ORDER-123456",
    "transactionId": "a3f91b2c-...",
    "amount": 1050,
    "currency": "EUR",
    "status": "success"
  }
}
FieldType · formatMeaning
event_idstring · evt_ + 32 hexUnique per event and identical across retries. Store it and ignore an event you have already handled.
typestring · enumpayment.succeeded or payment.failed. Treat any unknown type as "ignore", not "fail". payment.failed reports one declined attempt, not the end of the order: the payment stays open, the customer can try again, and a payment.succeeded can follow. Each attempt the bank declines sends one payment.failed; a failed 3-D Secure check may send none, depending on the terminal's provider.
created_atinteger · unix secondsWhen the event was created.
data.merchantOrderIdstringYour order number.
data.transactionIdstring · UUIDMyPay's identifier of the payment, the same as on Retrieve transaction.
data.amountinteger · centsThe amount the payment was created for. Compare it with your order.
data.currencystring · ISO 4217As created.
data.statusstring · enumsuccess or failure. Anything that is not exactly success means no money was taken.

Verifying the signature

The header is t=<unix seconds>,v1=<hex>. Compute HMAC-SHA256(webhook_secret, "<t>.<raw body>") and compare it to v1.

Four rules, each of which matters:

  1. Sign the raw bytes you received. Re-serialising the JSON produces different bytes and the signature will not match.
  2. Reject anything older than about 5 minutes. t is inside the signed string, so it cannot be edited — which is the only reason this check stops a captured webhook being replayed tomorrow.
  3. Accept any matching v1. During a secret rotation the header carries two of them, one per secret, so a half-updated fleet keeps working.
  4. Compare in constant time.
JavaScript
const crypto = require('node:crypto');

function verify(header, rawBody, secret, toleranceSeconds = 300) {
  const parts = Object.create(null);
  const signatures = [];
  for (const part of String(header).split(',')) {
    const [k, v] = part.trim().split('=', 2);
    if (k === 't') parts.t = v;
    else if (k === 'v1') signatures.push(v);
  }
  if (!parts.t || !/^\d+$/.test(parts.t) || signatures.length === 0) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t)) > toleranceSeconds) return false;

  const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
  return signatures.some(
    (s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)),
  );
}

A PHP reference implementation lives in both shop plugins: Mypay\Client::verifySignature() in the OpenCart plugin, DC_Mypay_Webhook::verify() in the WooCommerce one.

Retries

MyPay waits 10 seconds for an answer and does not follow redirects: a 301 from http to https, or to a URL with a trailing slash, counts as a failure. Give the exact final URL as webhookUrl.

If your endpoint does not answer 2xx, MyPay retries after 10s, 1min, 5min, 15min, 1h, 3h, 6h, 12h, 24h and 24h — eleven attempts over about three days. Retries are kept in MyPay's database, so a restart or a deploy on our side doesn't lose them. Every attempt carries the same event_id and a fresh t, so the signature differs each time and must be verified each time.

Nothing is resent after the last attempt. If your endpoint was down longer, read the payment with Retrieve transaction.

Answer 200 as soon as you have stored the event. Do the slow work afterwards; a handler that ships the order before replying will be retried, and will ship it twice.

Checking the amount

MyPay cannot do this for you. We know what the payment was created for, not what the order is worth — only your shop holds that. Before marking an order paid, confirm that data.amount and data.currency match the order. A payment opened for one cent against a five hundred euro order looks perfectly consistent to us.

Examples

All MyPay API calls must be made from your backend server, never from the frontend. The frontend never sees an API key.

Frontend — pay button

JavaScript
document.getElementById('payNow').onclick = async () => {
  const response = await fetch('/create-payment', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ orderId: 'ORDER-789' })
  });

  const { redirectUrl } = await response.json();
  window.location.href = redirectUrl; // Redirect to MyPay checkout
};

Backend — payment creation

JavaScript
app.post('/create-payment', async (req, res) => {
  const { orderId } = req.body;

  // Get order details from your database — never from the browser.
  const order = await getOrderFromDatabase(orderId);

  const response = await fetch(`${SERVICE_URL}/api/webhooks/transaction`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      // The checkout key. This server does not need to be able to refund.
      'Authorization': `Bearer ${process.env.MYPAY_CHECKOUT_KEY}`
    },
    body: JSON.stringify({
      subsystemId: process.env.MYPAY_SUBSYSTEM_ID,
      amount: order.totalCents, // an integer: 1050 for 10.50 EUR
      currency: 'EUR',
      merchantOrderId: orderId,
      successUrl: 'https://yoursite.com/success',
      cancelUrl: 'https://yoursite.com/cancel',
      webhookUrl: 'https://yoursite.com/webhook',
      customer: { email: order.customerEmail },
      orderItems: order.items // [{ name, quantity, price }], price in cents
    })
  });

  const result = await response.json();
  if (response.ok && result.success) {
    res.json({ redirectUrl: result.redirectUrl });
  } else {
    // result.message says why. A 409 means this order number already has a
    // payment for a different amount — look into it, do not retry.
    res.status(400).json({ error: result.message });
  }
});

Replace example values and credentials before use.

Backend — webhook handler

JavaScript
// The raw body is required: verification is over bytes, not over parsed JSON.
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  const raw = req.body.toString('utf8');

  if (!verify(req.headers['mypay-signature'], raw, process.env.MYPAY_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(raw);

  if (await alreadyHandled(event.event_id)) return res.sendStatus(200);

  if (event.type === 'payment.succeeded') {
    const order = await getOrderFromDatabase(event.data.merchantOrderId);

    // Only your shop can make this check.
    const sameAmount =
      event.data.amount === order.totalCents &&
      event.data.currency === order.currency;

    if (sameAmount) await markPaid(order.id);
  }

  await recordHandled(event.event_id);
  res.sendStatus(200); // Fulfil afterwards, not before answering.
});

Backend — payment status

The checkout key is enough.

JavaScript
async function getPaymentStatus(orderId) {
  const url = new URL(`${SERVICE_URL}/api/webhooks/transaction/${encodeURIComponent(orderId)}`);
  url.searchParams.set('subsystemId', process.env.MYPAY_SUBSYSTEM_ID);

  const response = await fetch(url, {
    headers: { 'Authorization': `Bearer ${process.env.MYPAY_CHECKOUT_KEY}` }
  });

  if (response.status === 404) return null; // no payment for this order
  const result = await response.json();
  if (!response.ok || !result.success) throw new Error(result.message);

  return result.transaction; // { status, amount, refundedAmount, currency, expired, customer, ... }
}

Backend — refund

Needs a full access key. If all you hold is a checkout key, refund from your MyPay account instead — the call below will be refused with 403.

JavaScript
app.post('/refund-transaction', async (req, res) => {
  const { orderId, amount, description } = req.body;
  // Names this refund: the same when it is retried, new once a refund is recorded.
  const refundKey = `${orderId}-${await refundedSoFar(orderId)}`;

  const response = await fetch(`${SERVICE_URL}/api/webhooks/refund/${orderId}`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${process.env.MYPAY_FULL_ACCESS_KEY}`,
      'Idempotency-Key': refundKey
    },
    body: JSON.stringify({
      subsystemId: process.env.MYPAY_SUBSYSTEM_ID,
      amount,      // in cents; omit for a full refund
      description  // optional, 25 characters
    })
  });

  const result = await response.json();
  if (response.ok && result.success) {
    await updateOrderStatus(orderId, 'REFUNDED');
    res.json({ message: 'Refund processed successfully' });
  } else if (response.status === 409) {
    // Not confirmed yet: record nothing, and retry later with the same key.
    res.status(409).json({ error: result.message });
  } else {
    res.status(400).json({ error: result.message || 'Refund failed' });
  }
});

Replace example values and credentials before use.

Shop plugins

The MyPay plugins for WooCommerce and OpenCart do all of the above for you. Five things worth knowing:

  • The plugin and the Service URL change together, never one without the other. This service reads amounts in cents; the older service at checkout.mypay.eu reads them in euros. A new plugin still pointed at the old Service URL charges 100 times the price (1050 is read as 1050.00 EUR). An older plugin pointed at this service sends "10.00", and every payment it creates is refused with 400; an older WooCommerce plugin also sends a 12 EUR refund as 12.0, which reads as 12 cents and is refunded as such. Ask MyPay for the current build: the version number alone doesn't tell the two apart. So, per shop: turn MyPay off in the shop, install the new plugin, then in one save set this Service URL, its keys and MyPay on, and place a test order.
  • They read the key's role from its prefix and adapt. With a mpf_ key the order page refunds through MyPay. With a mpc_ key it does not call us at all: the refund is recorded against the order in your shop, the plugin says so plainly, and you return the money from your MyPay account. No failed call, no error, no order that claims a refund which never happened.
  • They send the customer's details from the order (name, email, phone, billing and shipping address), so the payment page asks the customer for nothing.
  • They verify the webhook signature, and ignore anything that does not verify. Without a webhook secret in the settings they don't offer MyPay at checkout at all, because no payment could ever be confirmed.
  • Refunds are safe to retry. If MyPay can't confirm a refund, the plugin says so instead of calling it failed. In OpenCart, the order is left unchanged: click Refund again with the same amount. WooCommerce records a refund before sending it, so there, delete that refund and create it again with the same amount. Either way the plugin sends the same Idempotency-Key, and you get the first refund's result instead of a second refund.

Last updated: October 2026