Integration guide
For developers building a Yehulu mini app. The API follows telebirr's SuperApp mini-app API, so existing telebirr integrations carry over with a new base URL and keys.
How it fits together
appSecret and RSA private key must never reach the page. Everything the page does goes through the wallet bridge; everything signed happens on your server.Sandbox and production
Every app has two environments with their own X-APP-Key and appSecret. The app's status decides which one the wallet uses, and your server has to use the matching keys.
| Sandbox | Production | |
|---|---|---|
| When | From registration until a version is approved | Once Yehulu approves a version |
| Who can open the app | Your testers, and the portal's Sandbox tab | Every wallet user (and your testers) |
| Keys | Sandbox X-APP-Key and appSecret, issued at registration | Production X-APP-Key and appSecret, issued in the portal after approval |
| Money | Never moves. The full flow runs, PIN included | Real: the customer is charged, you're paid and settled |
| Customer checks | PIN only, since nothing is charged | PIN, plus the customer's balance, KYC level and limits |
| Notifications, queryOrder, refunds | Work the same, without moving money | Work the same, with real money |
INVALID_ACCESS_TOKEN), so the page never signs in. Issue production credentials under Mini apps → Overview → Get production credentials, replace the X-APP-Key and appSecret on your server and drop the cached fabric token. Your RSA keys and Yehulu's public key stay the same.Credentials
| Value | Where it comes from | Used for |
|---|---|---|
appid | Shown in the portal (16 digits) | Every request's biz_content |
merch_code | Your merchant code | Orders, queries and refunds |
X-APP-Key | One for sandbox, one for production (Overview tab) | Header on every call; picks the environment |
appSecret | Shown once when issued; issue a new one in the portal if lost | Getting a fabric token |
| RSA key pair | Generate it in the portal (the private key downloads) or upload your public key. 2048 bits or more | Signing your requests, the rawRequest and version manifests |
| Yehulu's public key | Keys tab | Verifying Yehulu's responses and notifications |
Base URL: https://<yehulu-api-host>/merchants/apiaccess/payment/gateway. Your portal shows the exact URL and the JS SDK link under Mini apps → Overview. Sandbox and production use the same URL; the X-APP-Key decides which environment a call runs in, and sandbox never moves real money.
Requests and signing
Every gateway call except the fabric token is a signed JSON body. Send X-APP-Key and Authorization (the fabric token, with or without Bearer) as headers.
| Field | Rule |
|---|---|
timestamp | Unix time in seconds or milliseconds, within 10 minutes of now |
nonce_str | Random, up to 32 characters, never reused |
method | The method named for each endpoint below |
version | 1.0 |
sign_type | SHA256WithRSA |
sign | Base64 signature of the canonical string |
biz_content | The call's own fields |
import crypto from "node:crypto";
// Fields that never take part in the signature.
const EXCLUDED = new Set(["sign", "sign_type", "header", "refund_info", "openType", "raw_request", "wallet_reference_data"]);
// The signed string: top-level fields plus every biz_content field, sorted by name, as k=v
// joined by &. Empty values stay in (as "key="); objects are written as compact JSON.
export function canonical(body) {
const fields = { ...body, ...(body.biz_content ?? {}) };
delete fields.biz_content;
return Object.keys(fields)
.filter((k) => !EXCLUDED.has(k) && fields[k] !== undefined)
.sort()
.map((k) => `${k}=${typeof fields[k] === "object" ? JSON.stringify(fields[k]) : fields[k]}`)
.join("&");
}
// SHA256WithRSA, RSA-PSS with a 32-byte salt (PKCS#1 v1.5 signatures are accepted too).
export function sign(body, privateKeyPem) {
return crypto
.sign("sha256", Buffer.from(canonical(body)), {
key: privateKeyPem,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: 32,
})
.toString("base64");
}
export function signedRequest(method, bizContent, privateKeyPem) {
const body = {
timestamp: String(Math.floor(Date.now() / 1000)),
nonce_str: crypto.randomBytes(16).toString("hex"),
method,
version: "1.0",
biz_content: bizContent,
};
return { ...body, sign_type: "SHA256WithRSA", sign: sign(body, privateKeyPem) };
}Responses are { result: "SUCCESS", code: "0", msg, nonce_str, biz_content, sign_type, sign }, signed by Yehulu. Errors are { result: "FAIL", code, msg } with an HTTP error status.
The JS SDK
Add the SDK to your page with one script tag. It gives you window.ma, which talks to the wallet around your page (the web wallet or the mobile app). The calls match telebirr's SDK.
<script src="https://<yehulu-api-host>/merchants/public/v1/miniapp/js-sdk.js"></script>| Call | Returns | Use it to |
|---|---|---|
ma.native("getMiniAppToken", { appId }) | { token } | Sign the customer in (step 2) |
ma.native("startPay", { rawRequest }) | { resultCode, msg } | Open the wallet's payment sheet (step 4) |
ma.showToast({ title }) | nothing | Show a short message in the wallet |
ma.setNavigationBarTitle({ title }) | nothing | Set the title bar above your page |
ma.close() | nothing | Close your app and return to the wallet |
Every call returns a promise. It rejects with err.code no_host when the page isn't running inside the wallet (for example opened directly in a browser), and timeout if the wallet doesn't answer. Calls made before the wallet is ready wait for it, so you can call getMiniAppToken as soon as the page loads.
1. Get a fabric token
/payment/v1/tokenPOST https://<yehulu-api-host>/merchants/apiaccess/payment/gateway/payment/v1/token
X-APP-Key: <your X-APP-Key>
Content-Type: application/json
{ "appSecret": "<your appSecret>" }
→ { "token": "…", "effectiveDate": "…", "expirationDate": "…" } // valid for one hourCache it and fetch a new one shortly before it expires.
2. Sign the customer in
Your page never asks for a password: the wallet vouches for the customer.
<script src="https://<yehulu-api-host>/merchants/public/v1/miniapp/js-sdk.js"></script>
<script>
// Ask the wallet for a one-time token for the signed-in customer…
const { token } = await ma.native("getMiniAppToken", { appId: "<your appid>" });
// …and send it to your server, which exchanges it with Yehulu (below).
await fetch("/login", { method: "POST", body: JSON.stringify({ token }) });
</script>/payment/v1/auth/authTokenPOST https://<yehulu-api-host>/merchants/apiaccess/payment/gateway/payment/v1/auth/authToken
X-APP-Key: <your X-APP-Key>
Authorization: <token from /payment/v1/token>
signedRequest("payment.applyh5token", {
appid: "<your appid>",
access_token: "<token from getMiniAppToken>",
trade_type: "InApp",
resource_type: "OpenId",
})
→ biz_content: { open_id, identityId, identityType, walletIdentityId,
nickName?, identifier?, status? }- The page token is single-use and lasts 5 minutes.
open_idis stable for that customer in your app; use it as your user id. nickName,identifier(phone) andstatusare included only when Yehulu approvedprofile.readfor your app and the customer allowed it.
3. Create the order, 4. take the payment
/payment/v1/merchant/preOrderPOST https://<yehulu-api-host>/merchants/apiaccess/payment/gateway/payment/v1/merchant/preOrder
signedRequest("payment.preorder", {
appid: "<your appid>",
merch_code: "<your merch_code>",
merch_order_id: "ORDER_20261007_0001", // your order id: letters, digits, _ (max 64)
trade_type: "InApp",
title: "2 x Macchiato",
total_amount: "120.00",
trans_currency: "ETB",
business_type: "BuyGoods",
timeout_express: "30m", // 1m to 120m
notify_url: "https://shop.example.com/yehulu/notify",
redirect_url: "https://shop.example.com/orders/0001",
})
→ biz_content: { merch_order_id, prepay_id }trade_typeis alwaysInApp,trans_currencyalwaysETB, andbusiness_typeBuyGoods.total_amounthas at most two decimals;title(shown on the payment sheet) is up to 512 characters.timeout_expressis how long the customer has to pay, from1mto120m(default120m). After that the order expires and you get anExpirednotification.notify_urlandredirect_urlmust be https on your app's domain or one of its allowed domains.- Sending the same
merch_order_idwith the same amount again returns the same unpaid order, so retries are safe. A paid or closed order id can't be reused.
// On your server: sign the five checkout fields into a rawRequest. Values are not
// URL-encoded, the base64 signature included.
const fields = { appid, merch_code, nonce_str, prepay_id, timestamp };
const rawRequest =
Object.keys(fields).sort().map((k) => `${k}=${fields[k]}`).join("&") +
"&sign=" + sign(fields, privateKeyPem) + "&sign_type=SHA256WithRSA";
// In the page:
const { resultCode, msg } = await ma.native("startPay", { rawRequest });
// "1" paid · "2" cancelled by the customer · "0" failed. Show the result, then
// wait for the notification (or queryOrder) before you hand over the goods.resultCode "0" comes with a msg for the customer, for example when their balance, KYC level or limits don't allow the payment. No money moves in that case and the order stays payable until it expires, so the customer can try again.
PAY_SUCCESS.5. Payment notifications
POST <your notify_url> (signed by Yehulu)
{
"notify_time": "1791354694000",
"appid": "…", "merch_code": "…",
"merch_order_id": "ORDER_20261007_0001",
"payment_order_id": "TXN-PRYN9NVBWB",
"total_amount": "120.00", "trans_currency": "ETB",
"trans_id": "…",
"trans_end_time": "1791354690000",
"notify_url": "https://shop.example.com/yehulu/notify",
"trade_status": "Completed", // or "Failure", "Expired"
"sign_type": "SHA256WithRSA", "sign": "…"
}
← reply HTTP 200 once you've recorded it- Verify the signature with Yehulu's public key before trusting it.
- Reply with HTTP 200. Until then Yehulu retries for about a day (after 1, 5, 15 and 30 minutes, then 1, 2, 4, 8 and 8 hours). Redirects aren't followed.
- You can see every delivery and resend one from the portal's Notifications tab.
Check an order
/payment/v1/merchant/queryOrderMethod payment.queryorder, biz_content { appid, merch_code, merch_order_id }.
| order_status | Meaning |
|---|---|
WAIT_PAY | Created, not paid yet |
PAYING | Payment in progress |
PAY_SUCCESS | Paid |
PAY_FAILED | Failed, cancelled or expired |
ORDER_CLOSED | Closed |
REFUND_SUCCESS | Fully refunded |
Refund
/payment/v1/merchant/refundMethod payment.refund, biz_content { appid, merch_code, merch_order_id, refund_request_no, actual_amount, trans_currency: "ETB", refund_reason }.
- Partial refunds are allowed, several times, up to the amount paid.
refund_request_nois your idempotency key: the same number and amount return the same refund instead of a second one.- The rules are the same as refunds from the portal.
Submit a version
A version tells Yehulu which page to open and proves it's yours. Prove your domain first (Mini apps → Domain & versions). The automatic checks then confirm the domain, that the entry URL is https on an allowed domain, the manifest signature against your registered public key, the manifest content, that the page answers, and its content hash. A version that passes goes to Yehulu for review.
import crypto from "node:crypto";
const entryUrl = "https://shop.example.com/";
const version = "1.2.0";
// The manifest is plain JSON naming the app and version. Sign the exact string you send
// (not a canonical string): same key and algorithm as your API requests.
const manifest = JSON.stringify({ appCode: "<your app code>", version, entryUrl });
const manifestSignature = crypto
.sign("sha256", Buffer.from(manifest), {
key: privateKeyPem,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: 32,
})
.toString("base64");
// The content hash is the SHA-256 (hex) of the HTML your entry URL serves right now.
const html = await fetch(entryUrl).then((r) => r.text());
const contentHash = crypto.createHash("sha256").update(html).digest("hex");
// Paste version, entryUrl, manifest, manifestSignature and contentHash into
// Mini apps → Domain & versions → Submit a version.Test, then go live
Three ways to run your app, in the order you'll use them.
1. Sandbox in the portal
The fastest loop while building: no phone, no real customer. The portal opens your page with a test customer and logs every SDK call.
- Open the Sandbox tabMini apps → your app → Sandbox, then Start. Your page opens in a phone frame, signed in as the test customer.
- PayYour page calls startPay; the portal shows the payment sheet. PIN 123456 approves, 000000 declines.
- Check your serverYour notify_url receives the signed notification, and the Notifications tab shows the delivery.
- 1Sandbox tab, Start
- 2Your page signed in as the test customer
- 3Payment sheet, test PIN
- 4SDK call log after payment
2. Testers on a phone
The real wallet and real customer accounts, still in sandbox until a version is approved, so nothing is charged.
- Add testersMini apps → Testers: add up to 50 wallet phone numbers. Before going live, only they see the app.
- Open it from the walletThe tester opens Mini apps in their Yehulu wallet and taps your app, marked as a test app. They're signed in straight away.
- Pay with their own PINThe wallet's payment sheet opens. In sandbox the flow completes but no money moves.
- 1Testers tab, add a phone number
- 2Your app in the tester's Mini apps
- 3Your page, signed in
- 4Wallet payment sheet
3. Live
After Yehulu approves a version, every wallet user can open your app and payments are real.
- ApprovalYehulu reviews the version. When it's approved the app's status becomes Live.
- Switch to production keysOverview → Get production credentials. Put the production
X-APP-KeyandappSecreton your server before customers arrive (see Sandbox and production). - Real paymentsCustomers pay from their balance, subject to their KYC level and limits. You get notifications, the payments show in Transactions, and they settle to your bank with your other payments.
- 1Version approved, app Live
- 2Get production credentials
- 3Payment sheet, real balance
- 4Payment in the portal's Transactions
Errors
Failures come back as { result: "FAIL", code, msg }. The codes you're most likely to see:
| code | What it means | What to check |
|---|---|---|
INVALID_APP_KEY | X-APP-Key or appSecret not recognised | That both come from the same environment, and the secret wasn't replaced |
INVALID_TOKEN | Fabric token missing, wrong or expired | Get a new one; it lasts an hour and only works with the X-APP-Key it was issued for |
INVALID_ACCESS_TOKEN | The page's sign-in token was refused | Single-use, 5 minutes. After go-live, use production keys (sandbox keys can't exchange production tokens) |
APP_UNAVAILABLE | The app or merchant isn't active, or production keys used before approval | The app's status in the portal |
VERIFY_SIGN_FAILED | Signature rejected | The canonical string: sorted keys, biz_content flattened in, excluded fields left out. And the public key in the portal |
PUBLIC_KEY_MISSING | No RSA public key on the app | Upload one on the Keys tab |
INVALID_TIMESTAMP | Timestamp more than 10 minutes off | Your server clock |
DUPLICATE_NONCE | nonce_str already used | A fresh nonce_str for every call, including retries |
INVALID_APPID | appid doesn't belong to this X-APP-Key | The appid in biz_content |
INVALID_PARAMETER | A field is missing or wrong; msg names it | notify_url / redirect_url must be https on an allowed domain |
ORDER_ALREADY_PAID | That merch_order_id is paid or closed | Use a new order id |
REFUND_DUPLICATED | refund_request_no reused with a different amount | A new refund_request_no for a new refund |



