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"
}
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. |
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.
Knowing what happened, in the browser
The frame posts a message to your page when it finishes. Use ito 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.
}
});
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"
}
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}.
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 our provider 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.
You only receive callbacks for succeeded, failed and
refunded. 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": "provider_requested", "summary": "Card entered and sent to provider, visa ending 0010" },
{ "seq": 3, "kind": "redirect_issued", "summary": "Buyer sent to the issuer for authentication, frictionless flow" },
{ "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 provider_unavailable | Our provider 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, no authentication. |
|---|---|
| 4000 0000 0000 0010 | Approved after frictionless authentication. Expiry 12/27, CVC 212. |
| 4000 0000 0000 0003 | Authentication challenge. OTP 123456. |
| 4000 0000 0000 0011 | Declined during authentication. |
| 4000 0000 0000 0012 | Declined. |
Keep sandbox amounts under 10 EUR. The sandbox declines anything larger with a policy error that looks like a real decline and is not.
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.