Your customers find, book, chat with, call and pay verified Ofixers artisans without leaving your app. You open a session from your server, show the link in a web view or iframe, and listen for events. This guide covers everything you need.
Ofixers gives you a live key (ofx_live_…) and a test key (ofx_test_…). Keep them on your server only, never in an app, a browser or a public repository. If a key leaks, tell us and we'll revoke it at once.
When your customer taps "Book an artisan", your server asks Ofixers for a one-time link for that customer:
// Node.js (any language works — it's one HTTPS call)
const res = await fetch("https://api.ofixers.com/api/v1/partner/sessions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.OFIXERS_KEY}`, // ofx_live_…
},
body: JSON.stringify({
user: {
id: customer.id, // your own id for this customer
name: customer.fullName,
phone: customer.phone, // Nigerian mobile number
},
}),
});
const { data } = await res.json();
// data.url → https://widget.ofixers.com/s/… (works once, expires in 5 minutes)Send data.url to your app and open it straight away. Each session lasts about two hours; open a new one for the next visit.
Ofixers must have your site's origin (for example https://app.yourbank.com) on file, or the browser will refuse to show the widget.
<iframe
src="SESSION_URL"
allow="camera; microphone; clipboard-write"
style="width:100%;max-width:440px;height:720px;border:0;border-radius:16px">
</iframe>
<script>
window.addEventListener("message", (e) => {
if (e.origin !== "https://widget.ofixers.com" || e.data?.source !== "ofixers-widget") return;
if (e.data.type === "widget.close") closeYourModal();
if (e.data.type === "brief.paid") refreshYourOrders();
});
</script>Open the URL in a web view. Allow camera and microphone (for photos, video and calls).
import { WebView } from "react-native-webview";
<WebView
source={{ uri: sessionUrl }}
mediaPlaybackRequiresUserAction={false}
allowsInlineMediaPlayback
mediaCapturePermissionGrantType="grant"
onMessage={(e) => {
const event = JSON.parse(e.nativeEvent.data);
if (event.type === "widget.close") navigation.goBack();
}}
/>final controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel('OfixersWidget', onMessageReceived: (m) {
final event = jsonDecode(m.message);
if (event['type'] == 'widget.close') Navigator.pop(context);
})
..loadRequest(Uri.parse(sessionUrl));
// webview_flutter_android: grant camera/mic in onPermissionRequest.webView.settings.javaScriptEnabled = true
webView.settings.mediaPlaybackRequiresUserGesture = false
webView.webChromeClient = object : WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest) = request.grant(request.resources)
}
webView.addJavascriptInterface(object {
@JavascriptInterface fun postMessage(json: String) { /* handle event */ }
}, "OfixersWidget")
webView.loadUrl(sessionUrl)let config = WKWebViewConfiguration()
config.allowsInlineMediaPlayback = true
config.userContentController.add(self, name: "ofixers") // window.webkit.messageHandlers.ofixers
let webView = WKWebView(frame: .zero, configuration: config)
webView.load(URLRequest(url: URL(string: sessionUrl)!))
// Add NSCameraUsageDescription and NSMicrophoneUsageDescription to Info.plist.Every event has source: "ofixers-widget" and a type:
widget.ready — loaded and signed inwidget.close — the customer tapped closesession.expired — open a new sessionjob.posted — with job_idbrief.paid and work.approved — with job_id, brief_idThese are for updating your screens. For anything you rely on (records, accounting), use webhooks.
Ofixers POSTs JSON to your webhook URL for: job.posted, job.artisan_engaged, brief.paid, brief.arrived, brief.completed, brief.approved, dispute.opened, payment.refunded, revenue_share.credited, user_wallet.funded, user_wallet.transferred_to_partner, payout.sent and payout.failed. We retry for about a day if your server doesn't answer 2xx. Always check the signature:
import crypto from "crypto";
// Use the raw request body, not re-serialised JSON.
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300; // reject replays
return fresh && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
// header = req.headers["ofixers-signature"] → "t=1760000000,v1=…"Each customer gets their own wallet with an account number, shown in the widget. They fund it by bank transfer (a small deposit fee, shown in the widget, is taken from each transfer) and pay for jobs from it. Money in a customer wallet can't be withdrawn to a bank. Refunds from disputes go back to it.
With the customer's agreement you can move their balance to your own wallet. They approve with a code we text to their phone:
POST /api/v1/partner/balance-transfers { "user": { "id": "cust_123" }, "amount": 5000 }
→ { "request_id": "…" } // we text the customer a 6-digit code
POST /api/v1/partner/balance-transfers/{request_id}/confirm { "code": "123456" }You earn a share of Ofixers' commission on every completed job your customers book, credited to your wallet when the artisan is paid. Withdrawals only go to the bank account Ofixers verified for your company.
GET /api/v1/partner/account // balance
POST /api/v1/partner/withdrawals { "amount": 50000 } // or omit amount for everything
GET /api/v1/partner/withdrawals
PUT /api/v1/partner/payout-settings { "enabled": true, "frequency": "weekly", "min_amount": 20000 }Use your ofx_test_… key the same way. Sandbox sessions only see test artisans and use test money, so you can go through a whole job (book, agree a price, pay, arrive, approve) without touching real artisans or real money. Sandbox wallets have no account number: the customer taps "Add test money" in the widget instead.
Questions? Contact your Ofixers partner manager.