API v1.0.0 · JSON over HTTPS
Build on Jualink
The same sizing engine, marketplace and workflows as the website, for installers' job systems, lenders' loan platforms, monitoring vendors and our own apps.
- Base URL
- https://jualink.com/api/v1
- Endpoints
- 44 operations in 9 groups
- Health
- GET /api/v1/health
Start here
Quick start
curl -X POST https://jualink.com/api/v1/sizing/estimate \
-H "Content-Type: application/json" \
-d '{"countryCode":"NG","regionCode":"LA","propertyType":"HOME","backupHours":12,
"appliances":[{"key":"refrigerator","quantity":1},{"key":"television","quantity":1},
{"key":"standing_fan","quantity":4},{"key":"led_bulb","quantity":8},
{"key":"wifi_router","quantity":1},{"key":"laptop","quantity":2}]}'curl https://jualink.com/api/v1/financing/applications?status=SUBMITTED \
-H "Authorization: Bearer jl_live_XXXXXXXX_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"Security
Authentication
Signed-in session
For our website and apps. Sign in with POST /api/auth/sign-in/email and send the session cookie back. Requests that change something must carry an Origin header for this site, so other websites can't act for a signed-in visitor.
API key
For an organisation's own systems: Authorization: Bearer jl_live_… (jl_test_… outside production). A key acts as the team member who created it, limited to its scopes. It is shown once; we keep only a hash. Owners and admins create and revoke keys: financing partners at /lender/api, installers at /pro/api.
Device token
For monitoring hardware: Authorization: Device jldev_…. A token can only add readings for the one device it was issued to, and is shown once when the device is registered.
| Scope | Allows | Who can hold it |
|---|---|---|
| financing:read | List and read the applications customers sent to your organisation. | Financing partners |
| financing:write | Start reviews, request information, decline, make offers, mark loans funded and report repayment status. Includes financing:read. | Financing partners |
| projects:read | List quote-request leads and the projects your company was awarded. | Installers |
| projects:write | Submit proposals and record equipment orders, deliveries and serial numbers. Includes projects:read. | Installers |
| monitoring:write | Register inverter or battery monitors on systems your company installed and receive their ingestion tokens. | Installers |
| catalog:write | Reserved for supplier catalogue endpoints, which are not available yet. It can't be granted today. | Nobody yet (reserved) |
Conventions
Responses, errors and limits
HTTP/1.1 200 OK
Cache-Control: no-store
X-Request-Id: 6f1c…
{ "data": { … }, "meta": { … } }HTTP/1.1 422 Unprocessable Entity
{ "error": {
"code": "validation_failed",
"message": "Some fields are missing or invalid.",
"fields": { "appliances.0.quantity": "Too small: expected number to be >=1" } } }400 invalid_jsonthe body isn't JSON401 unauthenticatedno or invalid credentials403 forbiddenwrong account type, missing scope or cross-site request404 not_founddoesn't exist, or isn't yours409 conflictthe record's state doesn't allow it422 validation_failedseeerror.fields429 rate_limitedwait forRetry-Afterseconds500 internal_errorquote the X-Request-Id to support
Limits: 600 requests a minute per person or key, 120 per address for public endpoints, 20 estimates a minute per address and 120 monitoring pushes a minute per device. Bodies up to 1 MB. Lists return everything that belongs to you, except financing applications (the 200 most recently updated).
Endpoints
Platform
/api/v1/healthService health
Checks the API and its database. Returns 503 when the database is unavailable.
/api/v1/openapi.jsonOpenAPI 3.1 document
This API described as OpenAPI 3.1 (JSON Schema 2020-12). The document itself is returned, not wrapped in `data`.
/api/v1/meWho am I
The user (or API key) the request is authenticated as. Any valid API key may call it.
/api/v1/countriesActive countries and regions
Countries where the service is live or piloting, with region codes and sun-hour assumptions.
/api/v1/appliancesAppliance catalogue
Typical wattages and daily hours. With propertyType, appliances relevant to it come first.
Query parameters
| Field | Type | Notes |
|---|---|---|
| propertyType | "HOME" | "SHOP" | "OFFICE" | "SCHOOL" | "CHURCH" | "MOSQUE" | "CLINIC" | "FARM" | "RESTAURANT" | "HOTEL" | "FACTORY" | "APARTMENT_BUILDING" | "OTHER" | Optional |
Endpoints
Sizing
/api/v1/sizing/estimateEstimate a solar system
Runs the sizing engine with the country's configured prices and the region's sun hours. Nothing is stored. Mix catalogue items ({ key, quantity }) and custom ones ({ name, watts, quantity, hoursDay, hoursNight }).
Request body (JSON)
| Field | Type | Notes |
|---|---|---|
| countryCode* | string, 2+ chars, ≤ 2 chars | ISO code of an active country, e.g. NG |
| regionCode* | string, 1+ chars, ≤ 10 chars | Region code from GET /countries, e.g. LA for Lagos |
| propertyType* | "HOME" | "SHOP" | "OFFICE" | "SCHOOL" | "CHURCH" | "MOSQUE" | "CLINIC" | "FARM" | "RESTAURANT" | "HOTEL" | "FACTORY" | "APARTMENT_BUILDING" | "OTHER" | Required |
| appliances* | object[] | Required |
| appliances[]: a catalogue appliance | ||
| appliances[].key* | string, 1+ chars, ≤ 60 chars | Appliance key from GET /appliances |
| appliances[].quantity* | integer, ≥ 1, ≤ 500 | Required |
| appliances[].hoursDay | number, ≥ 0, ≤ 12 | Hours used 06:00–18:00; defaults to the catalogue value |
| appliances[].hoursNight | number, ≥ 0, ≤ 12 | Hours used 18:00–06:00; defaults to the catalogue value |
| appliances[].isCritical | boolean | Keep running in the Essential option; defaults to the catalogue value |
| appliances[]: or an appliance of your own | ||
| appliances[].name* | string, 1+ chars, ≤ 80 chars | Required |
| appliances[].watts* | integer, ≥ 1, ≤ 100000 | Running watts per unit |
| appliances[].quantity* | integer, ≥ 1, ≤ 500 | Required |
| appliances[].hoursDay* | number, ≥ 0, ≤ 12 | Required |
| appliances[].hoursNight* | number, ≥ 0, ≤ 12 | Required |
| appliances[].surgeFactor | number, ≥ 1, ≤ 8 | Optional |
| appliances[].dutyCycle | number, ≥ 0.05, ≤ 1 | Optional |
| appliances[].isCritical | boolean | Optional |
| appliances[].isMotorLoad | boolean | Optional |
| appliances[].isThreePhase | boolean | Optional |
| backupHours | integer, ≥ -9007199254740991, ≤ 9007199254740991 | Optional |
| batteryPreference | "NO_PREFERENCE" | "LITHIUM_LFP" | "LEAD_ACID" | Optional |
| hasThreePhaseSupply | boolean | Optional |
Endpoints
Assessments
/api/v1/assessmentsStart an assessment
Request body (JSON)
| Field | Type | Notes |
|---|---|---|
| countryId* | string (uuid) | Required |
| regionId* | string (uuid) | Required |
| city* | string, 2+ chars, ≤ 80 chars | Required |
| latitude | number, ≥ -90, ≤ 90 | Optional |
| longitude | number, ≥ -180, ≤ 180 | Optional |
| propertyType* | "HOME" | "SHOP" | "OFFICE" | "SCHOOL" | "CHURCH" | "MOSQUE" | "CLINIC" | "FARM" | "RESTAURANT" | "HOTEL" | "FACTORY" | "APARTMENT_BUILDING" | "OTHER" | Required |
| bedrooms | integer, ≥ 0, ≤ 50 | Optional |
| businessName | string, ≤ 120 chars | Optional |
/api/v1/assessments/{id}Get an assessment
Path parameters
| Field | Type | Notes |
|---|---|---|
| id* | string (uuid) | Required |
/api/v1/assessments/{id}/appliancesReplace the appliance list
Path parameters
| Field | Type | Notes |
|---|---|---|
| id* | string (uuid) | Required |
Request body (JSON)
| Field | Type | Notes |
|---|---|---|
| appliances* | object[] | Required |
| appliances[].applianceId | string (uuid) | null | Optional |
| appliances[].name* | string, 1+ chars, ≤ 80 chars | Required |
| appliances[].quantity* | integer, ≥ 1, ≤ 500 | Required |
| appliances[].watts* | integer, ≥ 1, ≤ 100000 | Required |
| appliances[].hoursDay* | number, ≥ 0, ≤ 12 | Required |
| appliances[].hoursNight* | number, ≥ 0, ≤ 12 | Required |
| appliances[].surgeFactor | number, ≥ 1, ≤ 8 | Optional |
| appliances[].dutyCycle | number, ≥ 0.05, ≤ 1 | Optional |
| appliances[].isCritical | boolean | Optional |
| appliances[].isMotorLoad | boolean | Optional |
| appliances[].isThreePhase | boolean | Optional |
/api/v1/assessments/{id}/usageSave backup and usage answers
Path parameters
| Field | Type | Notes |
|---|---|---|
| id* | string (uuid) | Required |
Request body (JSON)
| Field | Type | Notes |
|---|---|---|
| backupHoursDesired* | integer, ≥ -9007199254740991, ≤ 9007199254740991 | Required |
| batteryPreference | "NO_PREFERENCE" | "LITHIUM_LFP" | "LEAD_ACID" | Optional |
| gridHoursPerDay | number, ≥ 0, ≤ 24 | Optional |
| hasGenerator | boolean | Optional |
| generatorLitresPerWeek | number, ≥ 0, ≤ 10000 | Optional |
| monthlyFuelSpend | number, ≥ 0 | Optional |
| monthlyGridSpend | number, ≥ 0 | Optional |
| hasThreePhaseSupply | boolean | Optional |
| operatingHoursStart | integer, ≥ 0, ≤ 23 | Optional |
| operatingHoursEnd | integer, ≥ 0, ≤ 24 | Optional |
| operatingDaysPerWeek | integer, ≥ 1, ≤ 7 | Optional |
| expansionPlans | string, ≤ 500 chars | Optional |
/api/v1/assessments/{id}/completeRun the sizing engine and store the three options
Path parameters
| Field | Type | Notes |
|---|---|---|
| id* | string (uuid) | Required |
/api/v1/assessments/{id}/select-tierChoose Essential, Recommended or Maximum
Path parameters
| Field | Type | Notes |
|---|---|---|
| id* | string (uuid) | Required |
Request body (JSON)
| Field | Type | Notes |
|---|---|---|
| tier* | "ESSENTIAL" | "RECOMMENDED" | "MAXIMUM" | Required |
Endpoints
Catalogue
/api/v1/productsVerified products
Only products whose documentation the platform team reviewed (verificationStatus VERIFIED).
Query parameters
| Field | Type | Notes |
|---|---|---|
| category | "SOLAR_PANEL" | "INVERTER" | "BATTERY" | "CHARGE_CONTROLLER" | "SOLAR_GENERATOR" | "MOUNTING" | "CABLE" | "PROTECTION" | "MONITORING" | "PACKAGE" | "ACCESSORY" | Optional |
| q | string, 2+ chars, ≤ 80 chars | Search in name, model and manufacturer |
| country | string, 2+ chars, ≤ 2 chars | Only prices offered in this country |
/api/v1/products/{slug}A verified product with supplier offers
Path parameters
| Field | Type | Notes |
|---|---|---|
| slug* | string, 1+ chars, ≤ 160 chars | Required |
/api/v1/installersVerified installers
Query parameters
| Field | Type | Notes |
|---|---|---|
| country | string, 2+ chars, ≤ 2 chars | Optional |
| region | string, 1+ chars, ≤ 10 chars | Region code; needs country |
/api/v1/installers/{slug}A verified installer's public profile
Path parameters
| Field | Type | Notes |
|---|---|---|
| slug* | string, 1+ chars, ≤ 160 chars | Required |
Endpoints
Customer
/api/v1/quote-requestsAsk matched installers for proposals
Needs a completed assessment with a chosen option. consentToShare must be true: it records the customer's consent to share the assessment with matched installers.
Request body (JSON)
| Field | Type | Notes |
|---|---|---|
| assessmentId* | string (uuid) | Required |
| addressLine1* | string, 3+ chars, ≤ 160 chars | Required |
| addressLine2 | string, ≤ 160 chars | Optional |
| landmark | string, ≤ 160 chars | Optional |
| preferredStartDate | string (date-time) | Optional |
| wantsFinancing | boolean | Optional |
| budgetMax | number | Optional |
| notes | string, ≤ 2000 chars | Optional |
| consentToShare* | true | Required |
/api/v1/quote-requestsMy quote requests
/api/v1/quote-requests/{id}Compare proposals for a request
Path parameters
| Field | Type | Notes |
|---|---|---|
| id* | string (uuid) | Required |
/api/v1/quotes/{id}/acceptAccept a proposal and start the project
Path parameters
| Field | Type | Notes |
|---|---|---|
| id* | string (uuid) | Required |
/api/v1/projectsMy projects
/api/v1/projects/{id}A project I'm part of
For the customer, the awarded installer (session or projects:read key) and staff. Customer contact details are shown to the installer; financing details only to the customer and staff.
Path parameters
| Field | Type | Notes |
|---|---|---|
| id* | string (uuid) | Required |
/api/v1/paymentsMy payments
/api/v1/warrantiesWarranties on my systems
/api/v1/warranty-claimsClaim under a warranty
Request body (JSON)
| Field | Type | Notes |
|---|---|---|
| warrantyId* | string (uuid) | Required |
| issue* | string, 20+ chars, ≤ 3000 chars | Required |
| occurredAt | string (date-time) | Optional |
/api/v1/maintenance-requestsMy maintenance requests
/api/v1/maintenance-requestsRequest a maintenance visit
Request body (JSON)
| Field | Type | Notes |
|---|---|---|
| passportId* | string (uuid) | Required |
| type* | "SYSTEM_INSPECTION" | "PANEL_CLEANING" | "BATTERY_REPLACEMENT" | "INVERTER_REPAIR" | "SYSTEM_UPGRADE" | "TROUBLESHOOTING" | "PREVENTIVE_MAINTENANCE" | Required |
| description* | string, 10+ chars, ≤ 3000 chars | Required |
| urgency | "LOW" | "NORMAL" | "HIGH" | Optional |
| preferredDate | string (date-time) | Optional |
/api/v1/notificationsMy notifications
Available to every signed-in user, newest first.
Query parameters
| Field | Type | Notes |
|---|---|---|
| limit | integer, ≥ 1, ≤ 100 | Optional |
/api/v1/notifications/readMark notifications read
Request body (JSON)
| Field | Type | Notes |
|---|---|---|
| id | string (uuid) | Mark one notification; omit to mark all as read |
Endpoints
Installer
/api/v1/leadsQuote-request leads for my company
/api/v1/leads/{invitationId}A lead with the customer's assessment
Opening a lead marks it viewed. The customer is identified by first name until they accept a proposal.
Path parameters
| Field | Type | Notes |
|---|---|---|
| invitationId* | string (uuid) | Required |
/api/v1/leads/{invitationId}/quoteSend or revise a proposal
Only verified installers. Totals and tax are computed server-side from the items.
Path parameters
| Field | Type | Notes |
|---|---|---|
| invitationId* | string (uuid) | Required |
Request body (JSON)
| Field | Type | Notes |
|---|---|---|
| panelArrayKw* | number, ≤ 10000 | Required |
| batteryKwh* | number, ≥ 0, ≤ 100000 | Required |
| batteryChemistry* | "LITHIUM_LFP" | "LEAD_ACID" | Required |
| inverterKva* | number, ≤ 10000 | Required |
| items* | object[] | Required |
| items[].variantId | string (uuid) | null | Optional |
| items[].category* | "SOLAR_PANEL" | "INVERTER" | "BATTERY" | "CHARGE_CONTROLLER" | "SOLAR_GENERATOR" | "MOUNTING" | "CABLE" | "PROTECTION" | "MONITORING" | "PACKAGE" | "ACCESSORY" | Required |
| items[].description* | string, 2+ chars, ≤ 200 chars | Required |
| items[].brand | string, ≤ 80 chars | Optional |
| items[].model | string, ≤ 80 chars | Optional |
| items[].quantity* | number, ≤ 10000 | Required |
| items[].unitPrice* | number, ≥ 0 | Required |
| items[].warrantyYears | integer, ≥ 0, ≤ 30 | Optional |
| labourCost* | number, ≥ 0 | Required |
| otherCost | number, ≥ 0 | Optional |
| discount | number, ≥ 0 | Optional |
| pricesIncludeTax | boolean | Optional |
| estimatedInstallDate | string (date-time) | Optional |
| installDurationDays | integer, ≥ 1, ≤ 365 | Optional |
| validDays | integer, ≥ 3, ≤ 90 | Optional |
| workmanshipWarrantyMonths* | integer, ≥ 0, ≤ 240 | Required |
| maintenancePackage | string, ≤ 500 chars | Optional |
| maintenanceIncludedMonths | integer, ≥ 0, ≤ 120 | Optional |
| notes | string, ≤ 3000 chars | Optional |
/api/v1/installer/projectsProjects awarded to my company
/api/v1/projects/{id}/equipment-orderedMark equipment ordered
Path parameters
| Field | Type | Notes |
|---|---|---|
| id* | string (uuid) | Required |
Request body (JSON)
| Field | Type | Notes |
|---|---|---|
| notes | string, ≤ 1000 chars | Optional |
/api/v1/projects/{id}/equipment-deliveredMark equipment delivered
Path parameters
| Field | Type | Notes |
|---|---|---|
| id* | string (uuid) | Required |
Request body (JSON)
| Field | Type | Notes |
|---|---|---|
| notes | string, ≤ 1000 chars | Optional |
/api/v1/projects/{id}/equipmentRecord installed equipment serial numbers
After installation has started. A serial already recorded on another system is refused.
Path parameters
| Field | Type | Notes |
|---|---|---|
| id* | string (uuid) | Required |
Request body (JSON)
| Field | Type | Notes |
|---|---|---|
| items* | object[] | Required |
| items[].category* | "SOLAR_PANEL" | "INVERTER" | "BATTERY" | "CHARGE_CONTROLLER" | "MONITORING" | "PROTECTION" | "MOUNTING" | "ACCESSORY" | Required |
| items[].manufacturer* | string, 1+ chars, ≤ 80 chars | Required |
| items[].model* | string, 1+ chars, ≤ 80 chars | Required |
| items[].serialNumber* | string, 3+ chars, ≤ 80 chars | Required |
| items[].capacityLabel | string, ≤ 40 chars | Optional |
| items[].warrantyYears | integer, ≥ 0, ≤ 30 | Optional |
| items[].variantId | string (uuid) | Optional |
Endpoints
Financing
/api/v1/financing/applicationsApplications sent to my organisation
At most 200, most recently updated first. meta.counts has the pipeline counts per status.
Query parameters
| Field | Type | Notes |
|---|---|---|
| status | "SUBMITTED" | "UNDER_REVIEW" | "INFO_REQUIRED" | "APPROVED" | "REJECTED" | "OFFER_ACCEPTED" | "FUNDED" | "REPAYMENT_ACTIVE" | "COMPLETED" | "DELINQUENT" | "WITHDRAWN" | Optional |
/api/v1/financing/applications/{id}One application
Identity and bank numbers are masked unless ?reveal=true, which decrypts them while the customer's consent is active and writes an audit entry.
Path parameters
| Field | Type | Notes |
|---|---|---|
| id* | string (uuid) | Required |
Query parameters
| Field | Type | Notes |
|---|---|---|
| reveal | "0" | "1" | "true" | "false" | true returns decrypted identity and bank details (only while the customer's consent is active); every reveal is audited |
/api/v1/financing/applications/{id}/decisionReview, request information, decline or make an offer
Body by action: {action:"start_review"} | {action:"request_info", message} | {action:"reject", reason} | {action:"offer", amount, termMonths, annualRatePct, interestMethod, upfrontFees?, monthlyFee?, conditions?, expiresInDays?}.
Path parameters
| Field | Type | Notes |
|---|---|---|
| id* | string (uuid) | Required |
Request body (JSON)
| Field | Type | Notes |
|---|---|---|
| when action is "start_review" | ||
| when action is "request_info" | ||
| message* | string, 5+ chars, ≤ 2000 chars | Required |
| when action is "reject" | ||
| reason* | string, 3+ chars, ≤ 2000 chars | Required |
| when action is "offer" | ||
| amount* | number | Required |
| termMonths* | integer, ≥ 1, ≤ 120 | Required |
| annualRatePct* | number, ≥ 0, ≤ 200 | Required |
| interestMethod* | "REDUCING_BALANCE" | "FLAT" | Required |
| upfrontFees | number, ≥ 0 | Optional |
| monthlyFee | number, ≥ 0 | Optional |
| conditions | string, ≤ 2000 chars | Optional |
| expiresInDays | integer, ≥ 1, ≤ 60 | Optional |
/api/v1/financing/applications/{id}/fundedMark an accepted offer funded
The disbursed amount must match the accepted offer. Records the disbursement against the project's payment schedule.
Path parameters
| Field | Type | Notes |
|---|---|---|
| id* | string (uuid) | Required |
Request body (JSON)
| Field | Type | Notes |
|---|---|---|
| partnerLoanRef* | string, 2+ chars, ≤ 80 chars | Required |
| disbursedAmount* | number | Required |
| signedAt | string (date-time) | Optional |
/api/v1/financing/applications/{id}/repaymentReport repayment status
Path parameters
| Field | Type | Notes |
|---|---|---|
| id* | string (uuid) | Required |
Request body (JSON)
| Field | Type | Notes |
|---|---|---|
| status* | "REPAYMENT_ACTIVE" | "COMPLETED" | "DELINQUENT" | Required |
| nextPaymentDueAt | string (date-time) | Optional |
| outstandingBalance | number, ≥ 0 | Optional |
| note | string, ≤ 500 chars | Optional |
Endpoints
Monitoring
/api/v1/monitoring/devicesRegister a monitoring device on a Solar Passport
For the system's owner, its installer (session or monitoring:write key) or staff. Returns the device token once; registering the same provider and externalId again rotates it.
Request body (JSON)
| Field | Type | Notes |
|---|---|---|
| provider* | string | Required |
| externalId* | string, 1+ chars, ≤ 120 chars | Required |
| displayName* | string, 2+ chars, ≤ 80 chars | Required |
| passportId* | string (uuid) | Required |
/api/v1/monitoring/readingsPush readings from a device
Authenticate with `Authorization: Device <token>`. Up to 500 readings per call; a reading for a time already stored is counted as a duplicate, so retries are safe. Power in W (gridPowerW + import / − export; batteryPowerW + charging / − discharging), batterySocPct 0–100.
Request body (JSON)
| Field | Type | Notes |
|---|---|---|
| readings* | object[] | Required |
| readings[].recordedAt* | string (date-time) | Required |
| readings[].pvPowerW | number, ≥ 0, ≤ 2000000 | Optional |
| readings[].loadPowerW | number, ≥ 0, ≤ 2000000 | Optional |
| readings[].gridPowerW | number, ≥ -2000000, ≤ 2000000 | Optional |
| readings[].batteryPowerW | number, ≥ -2000000, ≤ 2000000 | Optional |
| readings[].batterySocPct | number, ≥ 0, ≤ 100 | Optional |
| readings[].pvEnergyTodayWh | number, ≥ 0, ≤ 100000000 | Optional |
| readings[].statusCode | string, ≤ 40 chars | Optional |
Endpoints
Staff
/api/v1/admin/kpisMarketplace KPIs
Integrations
Webhooks
Payment providers
Configure these URLs in each provider's dashboard. A webhook that fails verification gets 401 and nothing is recorded. Verified events settle the payment once, however many times they arrive.
https://jualink.com/api/webhooks/payments/paystack: signed with HMAC-SHA512 of the body using your secret key (x-paystack-signature).https://jualink.com/api/webhooks/payments/flutterwave:verif-hashmust equal FLUTTERWAVE_WEBHOOK_HASH; the transaction is then re-verified with Flutterwave.https://jualink.com/api/webhooks/payments/mpesa?token=…: set automatically as the STK Push callback. Daraja callbacks aren't signed, so each payment gets its own token, checked against the CheckoutRequestID. We answer{"ResultCode":0,"ResultDesc":"Accepted"}.
Events to financing partners
Set an HTTPS endpoint at /lender/api. We POST JSON with ids and statuses only; fetch details with your key. One attempt, 5 second timeout, any 2xx counts as delivered. Retries aren't built yet, so reconcile with GET /financing/applications.
application.submittedA customer sent you a new financing application.application.withdrawnThe customer withdrew an application (and its data-sharing consent).application.info_providedThe customer answered your request for more information.offer.acceptedThe customer accepted your offer. Mark the loan funded once you disburse.offer.declinedThe customer declined your offer.pingA test event sent from the API & integrations page.
POST /your/endpoint
Content-Type: application/json
X-Jualink-Event: application.submitted
X-Jualink-Timestamp: 1790000000
X-Jualink-Signature: sha256=5d41…
{ "id": "0199…", "type": "application.submitted",
"createdAt": "2026-09-26T10:00:00.000Z",
"data": { "applicationId": "…", "reference": "FIN-2026-…",
"status": "SUBMITTED", "amountRequested": 3200000,
"depositAmount": 800000, "termMonths": 24,
"currency": "NGN", "projectReference": "PRJ-2026-…" } }import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the exact request body, before JSON.parse
export function verify(rawBody, headers, secret) {
const ts = headers["x-jualink-timestamp"];
const sig = headers["x-jualink-signature"] ?? "";
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = "sha256=" + createHmac("sha256", secret)
.update(`${ts}.${rawBody}`).digest("hex");
return sig.length === expected.length &&
timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}Your signing secret is shown at /lender/api. It is derived from a platform secret and your partner id, so it can't be rotated on its own yet; per-partner rotation is planned.
WhatsApp estimate assistant
Point the WhatsApp Cloud API webhook at https://jualink.com/api/webhooks/whatsapp. Meta's verification request is answered when hub.verify_token matches WHATSAPP_VERIFY_TOKEN; message deliveries must carry a valid X-Hub-Signature-256 (WHATSAPP_APP_SECRET). The assistant asks what the customer wants to power, the appliances, backup hours and location, shows the three options with prices, and hands over to the website with a private link. Numbers are never stored, only a keyed hash. Replies are sent when WHATSAPP_PROVIDER=meta, and messages are processed only while the whatsapp feature flag is on. Try it in the simulator.
Monitoring
Register a device on a Solar Passport with POST /monitoring/devices, then push readings to POST /monitoring/readings, or let the vendor push its own format to https://jualink.com/api/webhooks/monitoring/<adapter> with the same device token (header, or ?token= if the vendor can only store a URL). Readings are stored as sent and never estimated; a repeated timestamp is a duplicate, so retries are safe. Owners get a "Battery low" alert below 20% (at most daily). Offline alerts are sent by the background job markOfflineDevices() when the worker runs it; the energy view marks a device offline after 60 minutes without data either way. Production accepts readings only with MONITORING_INGEST_ENABLED=true.
{ "readings": [ {
"recordedAt": "2026-09-26T12:00:00Z",
"pvPowerW": 1850, // solar output
"loadPowerW": 640, // consumption
"gridPowerW": 0, // + import, − export
"batteryPowerW": 1150, // + charging, − discharging
"batterySocPct": 76.5,
"pvEnergyTodayWh": 5400 // optional, device's own counter
} ] }Adapters
genericGeneric JSON: An array of flat records, or an object with a `readings`/`data` array, or a single record. Field names are matched loosely (e.g. `timestamp`, `pv_power`, `soc`, `pv_power_kw`); kW and kWh fields are converted.
Adding a vendor: write a MonitoringAdapter in src/server/api/monitoring-adapters.ts that turns the vendor's JSON into canonical readings, and register it under a lowercase key. Validation, dedupe and alerts stay shared. No vendor-specific adapters exist yet.
Honest status
What's built and what's planned
Working today
- Every endpoint on this page. The page and the OpenAPI document are generated from the same definitions the server enforces.
- API keys with scopes; self-service for financing partners.
- Paystack, Flutterwave and M-Pesa webhooks.
- The WhatsApp estimate assistant and its hand-over to the website.
- Monitoring devices, readings, the generic JSON adapter and battery alerts.
- Signed partner webhooks, with a test event.
Not yet
- Webhook retries and per-partner secret rotation.
- Cursor pagination and idempotency keys (webhook settlement and readings are already idempotent).
- Vendor-specific monitoring adapters, and supplier catalogue endpoints (
catalog:writeis reserved). - Document downloads with an API key (downloads need a signed-in session).
- Calls from other websites' browsers: no CORS, the API is for servers and our own pages.