Recommended merchant pattern
The safe way to take a payment, step by step.
This is the safe way to take a payment with the Checkout API. The idea is simple: the customer's browser can be faked, so your server creates the payment link and your server confirms the payment before you ship anything.
Follow these five steps for every order.
- 1
Create the payment link on your server
When the customer clicks Pay, your server logs in (or reuses a token that has not expired) and calls Create one-time link with the amount, a new
externalUniqueId, alandingUrland acallbackUrl.Save the
externalUniqueIdandpaymentIdagainst the order in your database, with status "awaiting payment". - 2
Send the customer to the link
Redirect the customer's browser to
completionUrl, or send it by SMS, Telegram or email. They choose a payment method and pay on the Zoorya Pay page. - 3
The customer comes back
After paying, the browser goes to your
landingUrl. Show a "we are confirming your payment" page.This redirect is not proof of payment. Anyone can open that URL. Never release goods because of it.
- 4
Receive the webhook
Zoorya Pay POSTs to your
callbackUrlwhen the payment finishes. Treat it as a signal only: "go and check this order". See Handling webhooks. - 5
Confirm, then fulfil
Your server calls Get a payment with the
paymentId. Release the goods only ifstatusisSUCCESSFULandamountmatches your order.
Code#
const BASE = process.env.ZOORYA_BASE_URL; // https://sandbox.eclipse.et/eclipse-conductor/rest/v1
// Log in with the API key: token + tenantId, valid for about 15 minutes
async function login() {
const apiKey = process.env.ZOORYA_API_KEY;
const colon = apiKey.indexOf(":");
const res = await fetch(`${BASE}/authentication/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: apiKey.slice(0, colon), password: apiKey.slice(colon + 1) }),
});
if (!res.ok) throw new Error(`Log in failed: ${res.status}`);
return res.json(); // { headerValue, tenantId, expires, … }
}
// Step 1 — create the payment link
async function startPayment(auth, order) {
const res = await fetch(`${BASE}/tenants/${auth.tenantId}/organisations/${process.env.ZOORYA_ORG_ID}/payments`, {
method: "POST",
headers: { Authorization: auth.headerValue, "Content-Type": "application/json" },
body: JSON.stringify({
amount: order.total,
currency: "ETB",
type: "GLOBAL_PAYMENT_LINK",
externalUniqueId: order.reference, // new for every payment, letters and digits only
destinationWalletId: Number(process.env.ZOORYA_WALLET_ID),
landingUrl: `https://shop.example.com/orders/${order.id}/done`,
additionalFields: [
{ id: "HMACKey", value: process.env.ZOORYA_HMAC_KEY },
{ id: "callbackUrl", value: "https://shop.example.com/webhooks/zoorya" },
],
}),
});
if (!res.ok) throw new Error(JSON.stringify(await res.json()));
const payment = await res.json();
return { paymentId: payment.paymentId, url: payment.completionUrl }; // send the customer to url
}
// Step 5 — confirm before fulfilling
async function isPaid(auth, paymentId, expectedAmount) {
const res = await fetch(`${BASE}/tenants/${auth.tenantId}/payments/${paymentId}`, {
headers: { Authorization: auth.headerValue },
});
if (!res.ok) return false;
const payment = await res.json();
return payment.status === "SUCCESSFUL" && Number(payment.amount) === Number(expectedAmount);
}Also check#
- Reuse the token until
expiresinstead of logging in for every payment. Log in again when a call returns401. - You have not already fulfilled this order. A webhook can arrive more than once.
- Keep the create response on your server — it contains your HMACKey. Send only
completionUrlto the customer.

