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 API 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

The base URL is configured per environment. In the WooCommerce and OpenCart plugins it is the Service URL setting. The checkout service also uses SERVICE_DOMAIN to build redirect URLs.

Use the following format when calling the service:

HTTPS
{SERVICE_URL}/api

Prerequisites

MyPay issues you three values. All three are shown once, at the moment they are issued, and are never readable again — copy them then.

ValueLooks likeUsed for
Checkout API keympc_…Creating payments and reading their status. This is the key that lives in your shop plugin.
Full access API keympf_…Creating payments, and refunds and cancellations. Optional — issued on request.
Webhook signing secretwhsec_…Verifying that a webhook really came from MyPay.

You also need your subsystem ID, and a webhook URL on your server that can receive payment results.

Testing

The base URL and the keys are per environment: MyPay gives you a test Service URL with 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 test 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 test environment, and test cards are refused in production.

Never put the production key in a test shop, or the test 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 gets 400. This applies to refund and cancel too, even though they carry no other body fields.

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 rotated from your MyPay account. After a rotation the previous key keeps working for 7 days, so you can update your servers without downtime. A webhook secret's grace period is 24 hours.

API endpoints

1. Create transaction

POST/webhooks/transaction

Key required: checkout or full access

Initialize a new payment transaction.

JSON
{
  "subsystemId": "YOUR_SUBSYSTEM_ID",
  "amount": "10.50",
  "currency": "EUR",
  "successUrl": "https://yoursite.com/success",
  "cancelUrl": "https://yoursite.com/cancel",
  "merchantOrderId": "ORDER-123456",
  "customerEmail": "customer@example.com",
  "webhookUrl": "https://yoursite.com/webhook",
  "orderItems": [
    { "name": "Product Name", "quantity": 1, "price": 10.50 }
  ]
}

Replace example values and credentials before use.

Response:

JSON
{ "success": true, "redirectUrl": "https://{SERVICE_DOMAIN}?transactionId=abc123" }

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.

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": "Conflict: Order 'ORDER-123456' already has a payment for 10.5 EUR; refusing to serve it as 12 EUR." }

Read that as: something already opened a payment for this order at another price. Investigate it — do not retry with a new order number.

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.

Response:

JSON
{
  "success": true,
  "transaction": {
    "merchantOrderId": "ORDER-123456",
    "transactionId": "a3f91b2c-...",
    "status": "PARTIALLY_REFUNDED",
    "expired": false,
    "amount": "10.50",
    "refundedAmount": "5.25",
    "currency": "EUR"
  }
}
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 with a fresh link. 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.

An order number that does not exist, or belongs to another terminal, gets 404:

JSON
{ "success": false, "message": "Transaction not found" }

A 502 with "Failed to load transaction" means the lookup itself failed on our side. Nothing changed; retry later.

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.

JSON
{ "subsystemId": "YOUR_SUBSYSTEM_ID" }

Replace example values and credentials before use.

Response:

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

Not every provider supports this. Terminals on the Karditsa provider 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." }

Refunds are unaffected. If you are integrating against such a terminal, do not build a cancel path — build a full refund.

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.

JSON
{
  "subsystemId": "YOUR_SUBSYSTEM_ID",
  "amount": "5.25",
  "description": "Damaged item"
}

Replace example values and credentials before use.

Validation rules:

  • amount must be a positive number; omit it for a full refund
  • amount cannot exceed the remaining refundable balance
  • description is optional, 25 characters maximum

Response:

JSON
{ "success": true, "message": "Refund processed successfully" }

Concurrent refunds on the same order are serialised, so two partial refunds cannot both pass the remaining-balance check.

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.

Payload

JSON
{
  "event_id": "evt_9f2c41a8b3d04e7f8a1b2c3d4e5f6071",
  "type": "payment.succeeded",
  "created_at": 1757600000,
  "data": {
    "merchantOrderId": "ORDER-123456",
    "transactionId": "a3f91b2c-...",
    "amount": "10.50",
    "currency": "EUR",
    "status": "success"
  }
}
FieldNotes
event_idUnique per event and identical across retries. Store it and ignore an event you have already handled.
typepayment.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 declined attempt sends one payment.failed.
data.statussuccess or failure. Anything that is not exactly success means no money was taken.
data.amountA string, always. Compare in cents — "500" and "500.00" are the same amount.

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

If your endpoint does not answer 2xx, MyPay retries after 10s, 60s, 5min and 15min — five attempts over about twenty minutes. Every attempt carries the same event_id and a fresh t, so the signature differs each time and must be verified each time.

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.totalAmount,
      currency: 'EUR',
      merchantOrderId: orderId,
      successUrl: 'https://yoursite.com/success',
      cancelUrl: 'https://yoursite.com/cancel',
      webhookUrl: 'https://yoursite.com/webhook',
      customerEmail: order.customerEmail,
      orderItems: order.items
    })
  });

  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 =
      Math.round(Number(event.data.amount) * 100) === Math.round(order.totalAmount * 100) &&
      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, ... }
}

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;

  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}`
    },
    body: JSON.stringify({
      subsystemId: process.env.MYPAY_SUBSYSTEM_ID,
      amount,       // 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 {
    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. Two things worth knowing:

  • 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 verify the webhook signature, and ignore anything that does not verify. If you never paste a webhook secret into the plugin settings, no order will ever be marked paid.

Last updated: September 2026