Documentation

Everything you need to take your first payment.

Français

Overview

MOR.AI is the merchant of record for your sales. You create a payment, we collect the card and handle authentication, and we tell you the outcome.

Card details never reach your servers. You send us an amount and a reference, we return a URL, and the buyer enters their card on a page we serve. That keeps your compliance obligations minimal, which is the main reason to use a merchant of record at all.

What a payment looks like

  1. Your server calls POST /v1/payments with the amount and your own order reference.
  2. You get back a checkout_url. Embed it in an iframe or send the buyer to it.
  3. The buyer enters their card. If their bank asks for authentication, we handle it.
  4. We post the outcome to your callback URL, signed so you can verify it came from us.

The outcome arrives by callback rather than in the response to step one. A buyer can close the tab after paying and the payment still has to settle, so the callback is the authoritative answer.

Where to send requests

APIhttps://api.trymor.ai
Checkouthttps://pay.trymor.ai, returned to you as a full URL
Dashboardhttps://app.trymor.ai

The host is the same in both environments; your key is what decides which one you are in.

Environments

Sandboxmor_test_… keys. No money moves. Use the test cards below.
Livemor_live_… keys. Real cards, real money.

A sandbox key will not work against live and vice versa, so a key pasted into the wrong configuration fails immediately rather than after a real charge.

Authentication

Every request carries your API key as a bearer token.

Authorization: Bearer mor_test_YOUR_KEY_HERE

The key identifies you and is the only credential you need. Keep it on your server: it can create payments and read every payment you have made, so it does not belong in a browser, a mobile app, or anything a customer can read.

We store only a hash of your key, so we cannot recover it. If you lose it or suspect it has leaked, ask us and we will issue a new one and revoke the old.

Create a payment

One call, from your server.

POST https://api.trymor.ai/v1/payments
Authorization: Bearer mor_test_YOUR_KEY_HERE
Idempotency-Key: order-1043-attempt-1
Content-Type: application/json

{
  "amount": 4999,
  "currency": "EUR",
  "reference": "order-1043",
  "return_url": "https://yourshop.com/order/1043/complete",
  "customer": {
    "email": "marie@example.com",
    "address_line1": "12 Rue de Rivoli",
    "city": "Paris",
    "postal_code": "75001",
    "country": "FR"
  }
}

Fields

amountInteger, in the currency's smallest unit. 4999 is 49.99 EUR. Not a decimal and not a string: an integer cannot be silently rounded by anything between you and us.
currencyThree-letter ISO code, for example EUR.
referenceYour own order identifier. Echoed on every response and callback, so you never have to store our ids to reconcile.
return_urlWhere the buyer goes when they are finished. Must be https.
descriptionOptional. Shown to nobody; kept for your records.
metadataOptional. Up to 20 keys of your own data, returned unchanged.
customerOptional, and worth sending. See below.

The customer block

"customer": {
  "email": "marie@example.com",
  "phone": "+33612345678",
  "address_line1": "12 Rue de Rivoli",
  "address_line2": "Apt 4",
  "city": "Paris",
  "state": "Île-de-France",
  "postal_code": "75001",
  "country": "FR"
}

Every field is optional and so is the block itself. Payments work without it.

Send it anyway. The issuer decides whether to approve a payment and whether to make the buyer authenticate, and it makes that decision on what it is given. With an address that matches the card, more payments are approved outright. Without one, more get pushed into a 3DS challenge, and a challenge is where buyers abandon.

It also matters after the sale. A chargeback is defended with evidence about who bought what, and "we don't know who they were" is not a defence.

country is a two-letter ISO code. Everything else is free text, as printed on the card statement or shipping label.

Response

201 Created

{
  "id": "b0ddaea2-b485-443b-86ad-168c0635fcdb",
  "status": "created",
  "amount": 4999,
  "currency": "EUR",
  "reference": "order-1043",
  "checkout_url": "https://pay.trymor.ai/checkout/a17fa826-30c3-4f5c-85b1-974d03da17bf",
  "expires_at": "2026-08-12T11:36:02Z",
  "created_at": "2026-08-12T11:06:02Z"
}

The checkout URL is good for thirty minutes. After that, create another payment.

Idempotency

The Idempotency-Key header is required. Pick something tied to the order and the attempt, like order-1043-attempt-1.

If your connection drops you have no way to know whether we created the payment. Retry with the same key and you get the original response back, with the same id and the same checkout_url, rather than a second charge. The response carries Idempotent-Replay: true so you can tell.

Reusing a key with a different body is an error, not a replay: we would otherwise be charging for one order and answering about another.

Show the payment form

Embed the checkout URL in an iframe, or redirect the buyer to it. The iframe is usually better: the buyer stays on your site.

<iframe
  src="https://pay.trymor.ai/checkout/a17fa826-…"
  style="width:100%;height:560px;border:0"
  allow="payment">
</iframe>

The page carries no amount in its URL, so nothing a browser sends can change what is charged. It loads as a single document with no external requests, which matters for buyers on slow connections.

Authentication breaks out of the frame

When a bank asks the buyer to authenticate, many issuers refuse to render inside an iframe. We navigate the top-level window instead, and the buyer returns to your return_url afterwards. Your page should handle being navigated away from and back.

Sizing the frame

The frame tells you how tall it needs to be, on load and whenever that changes — a validation message appearing makes it taller. Resize rather than guessing a height, or the buyer ends up scrolling inside a box.

window.addEventListener("message", function (event) {
  if (event.origin !== "https://pay.trymor.ai") return;
  if (event.data.type === "mor:height") {
    frame.style.height = event.data.height + "px";
  }
});

Using your own pay button

By default the frame includes the amount and a pay button. If your checkout already has its own button and you want ours to be the card fields only, set the mode to fields under Checkout in your dashboard.

In that mode your button submits the frame:

payButton.addEventListener("click", function () {
  frame.contentWindow.postMessage(
    { type: "mor:submit" },
    "https://pay.trymor.ai"
  );
});

One thing changes with it. In fields mode the amount the buyer sees is the one you display, not the one we show them — so make sure it matches what you sent when creating the payment. We charge what the payment says regardless.

Language

The checkout renders in the buyer's browser language: English, French, German, Italian, Spanish and Dutch. An unsupported language falls back to English rather than showing blank labels.

If your buyers should always see one language regardless of their browser, pin it under Checkout in your dashboard. That is usually right when you sell to one market.

Appearance

Set these under Checkout in your dashboard, beside a preview that redraws as you change them. Fonts, colours, corner radius and field height, so the form can match the rest of your checkout. We take named values rather than CSS: a stylesheet on a page that accepts card numbers could hide a field or lay another input over ours, so that is not something we accept from anyone.

Knowing what happened, in the browser

The frame posts a message to your page when it finishes. Use it to update your interface, not to decide whether you were paid: a browser can be closed, and only the callback is authoritative.

window.addEventListener("message", function (event) {
  if (event.origin !== "https://pay.trymor.ai") return;

  if (event.data.type === "mor:result") {
    // Show a spinner and wait for your own server to confirm.
  }
});

3DS and 2D

Payments go one of two ways.

3DS

The default, and what you should use for all traffic. The bank verifies the cardholder before the payment is authorised, which means a buyer who later claims they did not make the purchase is the bank's problem rather than yours.

2D

No verification step. Approval is higher and there is no extra screen for the buyer, but there is no protection either: every "I did not authorise this" comes back to you.

Zero tolerance on 2D. It is enabled per account, for known and repeat customer flows only. Send cold or untested traffic down it and the account is closed.

Capped at 10. Anything above that goes 3DS whatever you send, because the rail is for test orders rather than sales.

Ask us to enable it on your account. Once it is on, send "rail": "2d" when you create the payment for the orders you want going that way. Everything without it goes 3DS, and so does a 2D request on an account that is not enabled.

{
  "amount": 4999,
  "currency": "EUR",
  "reference": "order-1043",
  "rail": "2d",
  "return_url": "https://yourshop.com/thanks"
}

Handle the callback

When a payment reaches a final state we post to your callback URL. This is the authoritative outcome.

POST https://yourshop.com/webhooks/mor
Mor-Signature: 4f8a3c…
Mor-Timestamp: 1786532801
Mor-Delivery-Id: 587299e9-dc68-463b-8e9a-40f90eb593f4
Mor-Event-Type: payment.succeeded
Content-Type: application/json

{
  "id": "587299e9-dc68-463b-8e9a-40f90eb593f4",
  "type": "payment.succeeded",
  "payment_id": "b0ddaea2-b485-443b-86ad-168c0635fcdb",
  "reference": "order-1043",
  "status": "succeeded",
  "amount": 4999,
  "currency": "EUR",
  "created_at": "2026-08-12T11:06:41Z"
}

Your signing secret

It appears under Checkout in your dashboard as soon as you save a callback URL. It is issued once and does not change, so whatever you build against keeps working.

Verify the signature

Do this before you act on anything in the body. Without it, anyone who learns your endpoint can tell you a payment succeeded.

The signature is HMAC-SHA256 over the timestamp, a full stop, and the raw request body, keyed with your webhook secret. Use the bytes as received: parsing and re-serialising the JSON changes it and the signature will not match.

// Node
const crypto = require("crypto");

function verify(rawBody, signature, timestamp, secret) {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(timestamp + "." + rawBody)
    .digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
# Python
import hmac, hashlib

def verify(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode(),
        timestamp.encode() + b"." + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

Compare in constant time, as both examples do. A comparison that returns early tells an attacker how much of a forged signature was right.

Reject anything with a timestamp more than a few minutes old. The timestamp is inside the signed material, so it cannot be altered, which makes this an effective guard against a captured delivery being replayed.

Be idempotent

We retry until you answer with a 2xx, so you will sometimes see the same delivery twice. Mor-Delivery-Id is stable across retries: record it and ignore a repeat.

Answer quickly

Return 2xx as soon as you have stored the event, then do the rest of your work. Anything other than 2xx, or no answer within fifteen seconds, counts as a failure and we try again.

Retries

Eight attempts over about a day: after 30 seconds, then 2, 10 and 30 minutes, then 2, 6 and 12 hours. If your endpoint is down for an hour you lose nothing. After that we stop, and you should reconcile with GET https://api.trymor.ai/v1/payments/{id}.

Refunds

Refund all or part of a payment. A payment can be refunded more than once, up to what is left of it.

POST https://api.trymor.ai/v1/payments/{payment_id}/refunds
Authorization: Bearer mor_test_YOUR_KEY_HERE
Content-Type: application/json

{
  "reference": "refund-1043-1",
  "amount": 2000,
  "reason": "Customer returned one item"
}
referenceRequired. Your own identifier for this refund. Two refunds on one payment need to be distinguishable in your records, and reusing one is refused rather than creating a second refund.
amountOptional. Integer in minor units. Omit it to refund everything still refundable, which is what most refunds are.
reasonOptional. Kept for your records and passed to the bank.

Response

202 Accepted

{
  "id": "9c1f2e3a-...",
  "payment_id": "b0ddaea2-...",
  "status": "requested",
  "amount": 2000,
  "currency": "EUR",
  "reference": "refund-1043-1",
  "remaining": 2999,
  "created_at": "2026-08-18T15:04:02Z"
}

202 rather than 201, deliberately: the refund is recorded and on its way to the bank, not complete. Treating this response as confirmation would have you tell a customer their money is back before it has left. The outcome arrives by callback, as refund.succeeded or refund.failed.

remaining is what is still refundable after this one, so you do not need a second call to find out.

Check what is refundable

GET https://api.trymor.ai/v1/payments/{payment_id}/refundable
Authorization: Bearer mor_test_YOUR_KEY_HERE
{
  "payment_id": "b0ddaea2-...",
  "paid": 4999,
  "refunded": 0,
  "pending": 2000,
  "remaining": 2999,
  "currency": "EUR"
}

pending is refunds already requested but not yet settled. They are held against the balance, which is why remaining can be lower than paid minus refunded. Without that, two refunds requested at the same moment could together exceed the payment.

What to expect

FeesOur fee is not returned on a refund. A refund costs you the sale, not the sale plus our margin.
PartialRefund as many times as you like until nothing remains. The payment moves to partially_refunded, then refunded.
FailuresA refund the bank refuses releases its hold, so the amount becomes refundable again and you can correct and retry.
Unsettled paymentsOnly a paid payment can be refunded. One still authorising or awaiting confirmation is refused, because refunding a charge that may not exist sends real money for nothing.

Statuses

createdThe payment exists. Nobody has entered a card.
processingA card was entered and is being authorised.
requires_3dsThe buyer is authenticating with their bank.
succeededPaid. Final.
failedDeclined or abandoned. Final.
refundedFully refunded.
partially_refundedRefunded in part.
disputedThe cardholder has raised a chargeback.
indeterminateWe do not yet know. Treat as neither paid nor failed.

indeterminate is worth understanding rather than ignoring. It means a call to the bank did not complete, so the card may or may not have been charged. Reporting it as failed would be worse: you would tell a customer nothing happened while their bank says otherwise. We resolve it by reconciliation, usually within minutes, and then send the callback.

Callbacks arrive for payment.succeeded, payment.failed, payment.refunded, refund.succeeded and refund.failed. The intermediate states are visible on GET https://api.trymor.ai/v1/payments/{id} if you want them.

Read a payment

GET https://api.trymor.ai/v1/payments/b0ddaea2-b485-443b-86ad-168c0635fcdb
Authorization: Bearer mor_test_YOUR_KEY_HERE
200 OK

{
  "id": "b0ddaea2-b485-443b-86ad-168c0635fcdb",
  "status": "succeeded",
  "amount": 4999,
  "currency": "EUR",
  "reference": "order-1043",
  "created_at": "2026-08-12T11:06:02Z",
  "updated_at": "2026-08-12T11:06:41Z"
}

The full history

Every step of a payment is recorded and readable. Useful when a customer asks what happened, and it is what a dispute is argued from.

GET https://api.trymor.ai/v1/payments/{id}/events
Authorization: Bearer mor_test_YOUR_KEY_HERE
{
  "payment_id": "b0ddaea2-…",
  "events": [
    { "seq": 1, "kind": "created",               "summary": "Payment created for 49.99 EUR, merchant reference order-1043" },
    { "seq": 2, "kind": "payment_submitted",     "summary": "Card entered and sent to bank, visa ending 0010" },
    { "seq": 3, "kind": "redirect_issued",       "summary": "Buyer sent to the issuer for authentication" },
    { "seq": 4, "kind": "notification_received", "summary": "Provider reported SUCCESS for 49.99 EUR" },
    { "seq": 5, "kind": "state_changed",         "summary": "State changed from processing to succeeded" }
  ]
}

Errors

Every error has the same shape.

{
  "error": {
    "code": "invalid_request",
    "message": "Amount must be a positive integer in the currency's minor unit for example 899 for 8.99 EUR.",
    "field": "amount",
    "trace_id": "5d5d63e3fea7b258"
  }
}

Branch on code. Read message in your logs. Quote trace_id when you ask us about it: we can find the request from that alone, without needing timestamps or guesswork.

400 invalid_requestSomething in the request is wrong. field says what.
401 unauthorizedMissing, unknown or revoked key, or the wrong environment.
404 not_foundNo such payment, or it is not yours.
409 idempotency_key_reusedThat key was used with a different body.
502 bank_unavailableThe bank did not answer. Safe to retry with the same idempotency key.
500 internal_errorOurs. Quote the trace id.

Test cards

Sandbox only. Any future expiry and any CVC unless stated.

4000 0000 0000 0002Approved.
4000 0000 0000 0010Approved after authentication without a challenge.
4000 0000 0000 0028Authentication challenge.
4000 0000 0000 0036Declined during authentication.
4000 0000 0000 0044Declined, insufficient funds.
4000 0000 0000 0051No response at all. The payment is left unresolved, as it would be if we never heard back. Worth testing: it is the case that costs money in production and the one nobody tries.

Any amount works in sandbox.

Callbacks in development

Your callback URL has to be reachable from the internet and use https, so localhost will not work. Use a tunnel such as cloudflared or ngrok while you build.

Before going live

  • Your live key is on your server only, and not in version control.
  • You verify the callback signature and reject anything that fails.
  • You ignore a repeated Mor-Delivery-Id rather than processing it twice.
  • You answer callbacks with 2xx before doing your own work.
  • You treat the callback as the outcome, not the browser.
  • Your idempotency keys are unique per attempt and you retry with the same one.
  • You have told us the website your customers buy from, exactly as it appears to them.

The last one matters more than it looks: it is what appears on your customer's bank statement, and a statement a customer does not recognise is the most common cause of a chargeback.

Something unclear or missing? Send us the trace_id from any response and we will look at the exact request.