Yehulu Wallet
Yehulu WalletYehulu WalletYehulu WalletYehulu WalletYehulu WalletYehulu WalletYehulu Wallet
0%
Loading
Merchants · Mini apps

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

1. Fabric tokenyour server → Yehulu
2. Sign-inpage → wallet → your server
3. Pre-orderyour server → Yehulu
4. startPaypage → wallet sheet
5. NotifyYehulu → your server
Keep secrets on your server
Your 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.

SandboxProduction
WhenFrom registration until a version is approvedOnce Yehulu approves a version
Who can open the appYour testers, and the portal's Sandbox tabEvery wallet user (and your testers)
KeysSandbox X-APP-Key and appSecret, issued at registrationProduction X-APP-Key and appSecret, issued in the portal after approval
MoneyNever moves. The full flow runs, PIN includedReal: the customer is charged, you're paid and settled
Customer checksPIN only, since nothing is chargedPIN, plus the customer's balance, KYC level and limits
Notifications, queryOrder, refundsWork the same, without moving moneyWork the same, with real money
Switch your server's keys when the app goes live
As soon as a version is approved, the wallet signs customers in to production, including your testers. A server still using sandbox keys can't exchange those sign-in tokens (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

ValueWhere it comes fromUsed for
appidShown in the portal (16 digits)Every request's biz_content
merch_codeYour merchant codeOrders, queries and refunds
X-APP-KeyOne for sandbox, one for production (Overview tab)Header on every call; picks the environment
appSecretShown once when issued; issue a new one in the portal if lostGetting a fabric token
RSA key pairGenerate it in the portal (the private key downloads) or upload your public key. 2048 bits or moreSigning your requests, the rawRequest and version manifests
Yehulu's public keyKeys tabVerifying 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.

FieldRule
timestampUnix time in seconds or milliseconds, within 10 minutes of now
nonce_strRandom, up to 32 characters, never reused
methodThe method named for each endpoint below
version1.0
sign_typeSHA256WithRSA
signBase64 signature of the canonical string
biz_contentThe call's own fields
sign.js (Node.js)
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.

In your page's <head>
<script src="https://<yehulu-api-host>/merchants/public/v1/miniapp/js-sdk.js"></script>
CallReturnsUse 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 })nothingShow a short message in the wallet
ma.setNavigationBarTitle({ title })nothingSet the title bar above your page
ma.close()nothingClose 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

POST/payment/v1/token
Request
POST 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 hour

Cache 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.

In your page
<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>
POST/payment/v1/auth/authToken
On your server
POST 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_id is stable for that customer in your app; use it as your user id.
  • nickName, identifier (phone) and status are included only when Yehulu approved profile.read for your app and the customer allowed it.

3. Create the order, 4. take the payment

POST/payment/v1/merchant/preOrder
On your server
POST 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_type is always InApp, trans_currency always ETB, and business_type BuyGoods. total_amount has at most two decimals; title (shown on the payment sheet) is up to 512 characters.
  • timeout_express is how long the customer has to pay, from 1m to 120m (default 120m). After that the order expires and you get an Expired notification.
  • notify_url and redirect_url must be https on your app's domain or one of its allowed domains.
  • Sending the same merch_order_id with the same amount again returns the same unpaid order, so retries are safe. A paid or closed order id can't be reused.
startPay
// 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.

The page result is for the customer, not proof of payment
Treat a payment as paid only after the signed notification or a queryOrder that says PAY_SUCCESS.

5. Payment notifications

What your notify_url receives
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

POST/payment/v1/merchant/queryOrder

Method payment.queryorder, biz_content { appid, merch_code, merch_order_id }.

order_statusMeaning
WAIT_PAYCreated, not paid yet
PAYINGPayment in progress
PAY_SUCCESSPaid
PAY_FAILEDFailed, cancelled or expired
ORDER_CLOSEDClosed
REFUND_SUCCESSFully refunded

Refund

POST/payment/v1/merchant/refund

Method 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_no is 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.

version.js (Node.js)
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.
Hash the page you'll serve
The content hash is checked when you submit. Submit after you deploy, so the hash matches what your entry URL serves.

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.

  1. Open the Sandbox tab
    Mini apps → your app → Sandbox, then Start. Your page opens in a phone frame, signed in as the test customer.
  2. Pay
    Your page calls startPay; the portal shows the payment sheet. PIN 123456 approves, 000000 declines.
  3. Check your server
    Your notify_url receives the signed notification, and the Notifications tab shows the delivery.
Sandbox in the portal
  1. 1Sandbox tab, Start
  2. 2Your page signed in as the test customer
  3. 3Payment sheet, test PIN
  4. 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.

  1. Add testers
    Mini apps → Testers: add up to 50 wallet phone numbers. Before going live, only they see the app.
  2. Open it from the wallet
    The tester opens Mini apps in their Yehulu wallet and taps your app, marked as a test app. They're signed in straight away.
  3. Pay with their own PIN
    The wallet's payment sheet opens. In sandbox the flow completes but no money moves.
Testers on a phone
  1. 1Testers tab, add a phone number
  2. 2Your app in the tester's Mini apps
  3. 3Your page, signed in
  4. 4Wallet payment sheet

3. Live

After Yehulu approves a version, every wallet user can open your app and payments are real.

  1. Approval
    Yehulu reviews the version. When it's approved the app's status becomes Live.
  2. Switch to production keys
    Overview → Get production credentials. Put the production X-APP-Key and appSecret on your server before customers arrive (see Sandbox and production).
  3. Real payments
    Customers 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.
Live
  1. 1Version approved, app Live
  2. 2Get production credentials
  3. 3Payment sheet, real balance
  4. 4Payment in the portal's Transactions

Errors

Failures come back as { result: "FAIL", code, msg }. The codes you're most likely to see:

codeWhat it meansWhat to check
INVALID_APP_KEYX-APP-Key or appSecret not recognisedThat both come from the same environment, and the secret wasn't replaced
INVALID_TOKENFabric token missing, wrong or expiredGet a new one; it lasts an hour and only works with the X-APP-Key it was issued for
INVALID_ACCESS_TOKENThe page's sign-in token was refusedSingle-use, 5 minutes. After go-live, use production keys (sandbox keys can't exchange production tokens)
APP_UNAVAILABLEThe app or merchant isn't active, or production keys used before approvalThe app's status in the portal
VERIFY_SIGN_FAILEDSignature rejectedThe canonical string: sorted keys, biz_content flattened in, excluded fields left out. And the public key in the portal
PUBLIC_KEY_MISSINGNo RSA public key on the appUpload one on the Keys tab
INVALID_TIMESTAMPTimestamp more than 10 minutes offYour server clock
DUPLICATE_NONCEnonce_str already usedA fresh nonce_str for every call, including retries
INVALID_APPIDappid doesn't belong to this X-APP-KeyThe appid in biz_content
INVALID_PARAMETERA field is missing or wrong; msg names itnotify_url / redirect_url must be https on an allowed domain
ORDER_ALREADY_PAIDThat merch_order_id is paid or closedUse a new order id
REFUND_DUPLICATEDrefund_request_no reused with a different amountA new refund_request_no for a new refund