Documentation
Everything you need to take your first payment.
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
- Your server calls
POST /v1/paymentswith the amount and your own order reference. - You get back a
checkout_url. Embed it in an iframe or send the buyer to it. - The buyer enters their card. If their bank asks for authentication, we handle it.
- 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
| API | https://api.trymor.ai |
|---|---|
| Checkout | https://pay.trymor.ai, returned to you as a full URL |
| Dashboard | https://app.trymor.ai |
The host is the same in both environments; your key is what decides which one you are in.
Environments
| Sandbox | mor_test_… keys. No money moves. Use the test cards below. |
|---|---|
| Live | mor_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
| amount | Integer, 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. |
|---|---|
| currency | Three-letter ISO code, for example EUR. |
| reference | Your own order identifier. Echoed on every response and callback, so you never have to store our ids to reconcile. |
| return_url | Where the buyer goes when they are finished. Must be https. |
| description | Optional. Shown to nobody; kept for your records. |
| metadata | Optional. Up to 20 keys of your own data, returned unchanged. |
| customer | Optional, 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"
}
| reference | Required. 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. |
|---|---|
| amount | Optional. Integer in minor units. Omit it to refund everything still refundable, which is what most refunds are. |
| reason | Optional. 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
| Fees | Our fee is not returned on a refund. A refund costs you the sale, not the sale plus our margin. |
|---|---|
| Partial | Refund as many times as you like until nothing remains. The payment moves to partially_refunded, then refunded. |
| Failures | A refund the bank refuses releases its hold, so the amount becomes refundable again and you can correct and retry. |
| Unsettled payments | Only 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
| created | The payment exists. Nobody has entered a card. |
|---|---|
| processing | A card was entered and is being authorised. |
| requires_3ds | The buyer is authenticating with their bank. |
| succeeded | Paid. Final. |
| failed | Declined or abandoned. Final. |
| refunded | Fully refunded. |
| partially_refunded | Refunded in part. |
| disputed | The cardholder has raised a chargeback. |
| indeterminate | We 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_request | Something in the request is wrong. field says what. |
|---|---|
| 401 unauthorized | Missing, unknown or revoked key, or the wrong environment. |
| 404 not_found | No such payment, or it is not yours. |
| 409 idempotency_key_reused | That key was used with a different body. |
| 502 bank_unavailable | The bank did not answer. Safe to retry with the same idempotency key. |
| 500 internal_error | Ours. Quote the trace id. |
Test cards
Sandbox only. Any future expiry and any CVC unless stated.
| 4000 0000 0000 0002 | Approved. |
|---|---|
| 4000 0000 0000 0010 | Approved after authentication without a challenge. |
| 4000 0000 0000 0028 | Authentication challenge. |
| 4000 0000 0000 0036 | Declined during authentication. |
| 4000 0000 0000 0044 | Declined, insufficient funds. |
| 4000 0000 0000 0051 | No 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-Idrather 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.