Errors
What the Moosyl API returns when a request fails, what each status means, and how to handle and retry errors safely.
When a request fails, the API responds with a non-2xx status and a short plain-text body that describes the problem. Validation errors are the exception: their body is JSON (sent as text/plain, so parse it yourself).
Response bodies
Most errors return the message as text:
HTTP/2 401
content-type: text/plain
Invalid API keyValidation errors (a missing or wrong-typed field) return 422 with a JSON body. Show summary to developers and use property to find the field:
{
"type": "validation",
"on": "body",
"property": "/amount",
"message": "Expected required property",
"summary": "Property 'amount' is missing",
"expected": { "transactionId": "", "amount": 0 },
"found": { "transactionId": "order_123" }
}on is where the problem is: body, query or params. The body can also include an errors array with every failing field.
HTTP statuses
| Status | Meaning | Retry? |
|---|---|---|
200 | Success. | |
400 | The request can't be processed as sent, for example a missing pass code or an amount of 0. | No. Fix the request. |
401 | The API key is missing or invalid. | No. Fix the key. |
403 | Your key is valid but not allowed here, usually a publishable key on a route that needs the secret key. | No. |
404 | The resource doesn't exist in this key's environment (Sandbox and Production are separate). | No. |
409 | The request conflicts with the current state: a reused transactionId, an already-paid request, or a closed checkout session. | No. Read the existing resource instead. |
422 | The request body, query or path parameters failed validation. | No. Fix the field. |
500 | Something failed on Moosyl's side. The body is always Internal server error. | Yes, with backoff. |
502 | The bank app's service failed. The message is safe to show to your customer. | Yes, after a short wait. |
Common errors
| Status | Message | Cause | Fix |
|---|---|---|---|
401 | API key is required | No Authorization header. | Send your key as Authorization: <key>, without Bearer. |
401 | Invalid API key | The key doesn't exist or was deleted. | Copy the key again from API Keys in the dashboard. |
403 | Unauthorized access | A publishable key was used on a secret-only route (creating payment requests, reading a payment, customers, products, prices, subscriptions, invoices). | Call this route from your server with the secret key. |
403 | Only secret API keys can create checkout sessions | A publishable key was used to create a checkout session. | Create the session on your server with the secret key. |
404 | Payment Request not found / PaymentRequest not found | No payment request with that ID or transactionId in this environment. | Check the ID and that you're using a key from the same environment. |
404 | Payment not found, Checkout session not found, Customer not found, Product not found, Price not found, Subscription not found, Invoice not found | The resource doesn't exist in this environment or organization. | Check the ID and environment. |
422 | Validation body (see above) | A required field is missing or has the wrong type. | Fix the field named in property. |
400 | Pass code is required for Bankily | A Bankily payment was submitted without the customer's payment code. | Collect the code in your payment UI. The SDK payment screens do this for you. |
400 | Invalid phone number | The phone number isn't a valid Mauritanian number. | Send the 8-digit local number, for example 22222222. |
400 | Amount must be a positive number / Price amount must be a positive number | amount is 0 or negative. | Send a whole amount in MRU greater than 0. |
400 | Amount is required when transactionId does not match an existing payment request | A checkout session was created with a new transactionId and no amount. | Pass amount, or use an existing paymentRequestId. |
400 | Simulated transfers are only available in sandbox | simulate-transfer was called with a Production key. | Use a Sandbox key. See Testing. |
409 | A payment request with transactionId "…" already exists in this environment | POST /payment-request with a transactionId you already used. | Fetch it with GET /payment-request/by-transaction/{transactionId} instead of creating it again. |
409 | Payment request is already paid | You tried to pay, or open checkout for, a request that's already paid. | Treat the order as paid. Check its payments instead of starting a new one. |
409 | Checkout session is not open | The session was completed, cancelled or has expired. | Create a new checkout session. |
502 | Payment failed due to a third party error, please try again later | The bank app's service rejected or didn't answer the request. | Ask the customer to try again in a moment. |
Handle errors with moosyl-sdk
moosyl-sdk throws an AppException for failed requests. It isn't exported, so check error.name. Its fields:
message: the API's message (for validation errors, the first issue'smessage).status: the HTTP status, when the API answered.code:connection_error,timeout(requests time out after 60 seconds) orabort_errorwhen the request never got an answer; otherwise the plain-text body orunknown.- Helpers:
isClientError,isServerError,isConnectionError,isTimeout.
import { Moosyl } from "moosyl-sdk";
const moosyl = new Moosyl(process.env.MOOSYL_SECRET_KEY!);
try {
const session = await moosyl.createCheckoutSession({
transactionId: "order_123",
amount: 2500,
successUrl: "https://example.com/orders/123/paid",
cancelUrl: "https://example.com/orders/123",
expiresInMinutes: 30,
});
// redirect to session.checkoutUrl
} catch (error) {
if (error instanceof Error && error.name === "AppException") {
const { status, message, code } = error as Error & { status?: number; code: string };
if (code === "connection_error" || code === "timeout") {
// No answer from Moosyl: safe to retry this call (see below).
} else if (status === 401 || status === 403 || status === 404) {
// Wrong key, key type or environment: fix configuration, don't retry.
} else {
console.error("Moosyl error", status, message);
}
}
throw error;
}Webhook signature failures throw WebhookSignatureError instead. See Webhooks.
Retrying safely
Your transactionId is what makes retries safe: it's unique per environment, so Moosyl never creates two payment requests for the same order.
- Checkout sessions are safe to retry. Creating a session with a
transactionIdthat already has a payment request reuses that request, and an open session for it is reused too. POST /payment-requestis not idempotent. A second call with the sametransactionIdreturns409. If a create call timed out, first look the request up withGET /payment-request/by-transaction/{transactionId}, and only create it if that returns404.- Retry connection errors, timeouts,
500and502, with a short backoff (for example 1 s, 2 s, 4 s). - Don't retry
400,401,403,404,409or422without changing the request. - Payments: don't resubmit a payment just because the call failed. Check the payment request's payments, or wait for the webhook, before asking the customer to pay again.
Use one transactionId per order
Generate transactionId from your own order ID, store it before calling Moosyl, and reuse it on every retry. That way a retry finds the existing payment request instead of creating a duplicate.
Webhook delivery failures
If your endpoint is down or returns an error, Moosyl retries the delivery. What's retried, how often, and how to skip duplicates is on the Webhooks page.
Testing
Test your Moosyl integration in the sandbox with test phone numbers and simulated transfers, without moving real money.
Going live
Move from the Sandbox to Production: verify your organization, create Production keys, choose your payment methods, set up webhooks and add the bank account for your payouts.