Developers
Send SMS and email, use Lynk AI, and take Mobile Money and card payments from your own app — with one REST API and JSON.
OpenAPI spec (JSON) · Get an API key
All requests go to:
https://www.lynkinfra.com/api/v1Content-Type: application/json and your key in the Authorization header.curl -X POST https://www.lynkinfra.com/api/v1/sms \
-H "Authorization: Bearer lynk_your_key" \
-H "Content-Type: application/json" \
-d '{"to": "0241234567", "message": "Hello from my app"}'Send your key as a Bearer token. Keep keys on your server — never in a website or mobile app.
Authorization: Bearer <your key>| Key | Looks like | Used for |
|---|---|---|
| Account key | lynk_… | SMS, email, WhatsApp and Lynk AI |
| Lynk Pay secret key | sk_test_… / sk_live_… | Payments. Test keys never move real money. |
Permissions. When you create an account key, you choose what it can use: sms, email, whatsapp, ai (chat) and images. A key used for anything else gets 403. Give each app its own key with only what it needs.
Every error has an HTTP status and a message you can show or log.
| Status | Meaning |
|---|---|
| 400 / 422 | Something in the request is missing or invalid. |
| 401 | The API key is missing or wrong. |
| 402 | Not enough wallet balance or messaging allowance. Top up and try again. |
| 403 | The key doesn't have permission for this service. |
| 404 | Not found (e.g. a payment reference). |
| 409 | Lynk Pay can't take this payment yet — see the code: PAYOUT_ACCOUNT_REQUIRED, DUPLICATE_REFERENCE, METHOD_UNAVAILABLE, PAYMENT_PENDING. |
| 503 | The service is temporarily unavailable. Try again later. |
| 500 / 502 | Something went wrong on our side. Retry with backoff. |
Error bodies by API:
{ "error": "Insufficient wallet balance" }{ "error": { "message": "…", "type": "invalid_request_error", "code": "model_not_found" } }{ "status": false, "message": "…", "code": "VALIDATION_ERROR" }POST/smsneeds sms
Sends one message to one or many numbers. Each part (160 characters, or 70 with emoji) per recipient uses one SMS from your allowance.
| Field | Type | Description |
|---|---|---|
| to* | string | string[] | A number or a list. Ghana numbers like 0241234567 or +233241234567; other countries with their country code. |
| message* | string | The text. Up to 6 parts. |
| sender | string | Your approved sender ID. Without it, Lynk's default sender is used. |
curl -X POST https://www.lynkinfra.com/api/v1/sms \
-H "Authorization: Bearer lynk_your_key" \
-H "Content-Type: application/json" \
-d '{"to": ["0241234567", "0201234567"], "message": "Your order is on its way!", "sender": "MYSHOP"}'{
"id": "cm…",
"status": "SENT",
"sender": "MYSHOP",
"recipients": 2,
"parts": 1,
"unitsUsed": 2,
"rejected": 0,
"invalidNumbers": []
}Numbers that couldn't be read are listed in invalidNumbers and aren't charged; numbers the network rejects are counted in rejected and refunded.
POST/emailneeds email
Sends a plain-text email under your business name. Replies go to your reply-to address. With a verified sending domain, it comes from your own address. Uses one email from your allowance.
| Field | Type | Description |
|---|---|---|
| to* | string | Recipient email address. |
| subject* | string | Subject line. |
| body* | string | Plain-text message. |
curl -X POST https://www.lynkinfra.com/api/v1/email \
-H "Authorization: Bearer lynk_your_key" \
-H "Content-Type: application/json" \
-d '{"to": "ama@example.com", "subject": "Your receipt", "body": "Thanks for your order!"}'{ "id": "cm…", "status": "SENT", "unitsUsed": 1 }POST/whatsappneeds whatsapp
Sends a WhatsApp message, charged per message from your wallet. Returns 503 while WhatsApp isn't available on your account.
| Field | Type | Description |
|---|---|---|
| to* | string | Phone number with country code. |
| message* | string | The text. |
{ "id": "cm…", "status": "SENT", "costCents": 12 }POST/aineeds ai
Ask Lynk AI a question. Lynk picks a suitable model, removes personal details before sending (based on your privacy setting), and charges your wallet for what the answer uses.
| Field | Type | Description |
|---|---|---|
| prompt | string | A single question. Send this or messages. |
| messages | object[] | A conversation: [{ "role": "user" | "assistant", "content": "…" }], ending with the user. Up to 100 messages. |
| mode | string | AUTO (default), QUALITY, FAST, ECONOMY or PRIVACY. |
curl -X POST https://www.lynkinfra.com/api/v1/ai \
-H "Authorization: Bearer lynk_your_key" \
-H "Content-Type: application/json" \
-d '{"prompt": "Write a short SMS announcing our weekend sale"}'{
"id": "cm…",
"completion": "Weekend sale! 20% off everything…",
"model": "…",
"usage": { "inputTokens": 18, "outputTokens": 42 },
"charged": { "amountCents": 1 },
"privacy": { "protectedCount": 0 }
}POST/chat/completionsneeds ai
GET/modelsneeds ai or images
Already using a chat-completions library? Point it at Lynk and use a Lynk key. The model is a Lynk mode:
| Field | Type | Description |
|---|---|---|
| lynk-auto | model | Best model for each request (default). |
| lynk-quality | model | The most capable available model. |
| lynk-fast | model | The quickest available model. |
| lynk-economy | model | The lowest-cost available model. |
| lynk-privacy | model | Only privacy-approved models. |
import OpenAI from "openai";
const lynk = new OpenAI({ apiKey: process.env.LYNK_API_KEY, baseURL: "https://www.lynkinfra.com/api/v1" });
const reply = await lynk.chat.completions.create({
model: "lynk-auto",
messages: [
{ role: "system", content: "You write friendly SMS for a bakery." },
{ role: "user", content: "Announce fresh bread at 7am." },
],
});
console.log(reply.choices[0].message.content);from openai import OpenAI
lynk = OpenAI(api_key="lynk_your_key", base_url="https://www.lynkinfra.com/api/v1")
reply = lynk.chat.completions.create(
model="lynk-auto",
messages=[{"role": "user", "content": "Announce fresh bread at 7am."}],
)
print(reply.choices[0].message.content)system and developer messages become instructions. Streaming isn't supported yet. The response also has a lynk object with served_by and charged_cents.
POST/images/generationsneeds images
Creates one image per request, returned as base64. Charged per image.
| Field | Type | Description |
|---|---|---|
| prompt* | string | What to draw. Up to 4,000 characters. |
| model | string | lynk-auto (default) or lynk-quality. |
| size | string | 1024x1024 (default), 1024x1536 or 1536x1024. |
curl -X POST https://www.lynkinfra.com/api/v1/images/generations \
-H "Authorization: Bearer lynk_your_key" \
-H "Content-Type: application/json" \
-d '{"prompt": "A bright photo of fresh bread on a wooden table", "size": "1024x1024"}'{
"created": 1790000000,
"data": [{ "b64_json": "iVBORw0KGgo…" }],
"output_format": "png",
"lynk": { "image_id": "cm…", "charged_cents": 5 }
}POST/pay/payments
Starts a Mobile Money or card payment and returns a link to send your customer to. Amounts are in pesewas (GH₵100.00 = 10000). Use a Lynk Pay secret key; test keys create test payments.
| Field | Type | Description |
|---|---|---|
| amount* | integer | What you want to receive, in pesewas. |
| channel* | string | MOBILE_MONEY or CARD. |
| email* | string | Customer's email, for their receipt. |
| currency | string | GHS (the only currency for now). |
| fee_bearer | string | merchant (fee comes out of your payout) or customer (added on top). Defaults to your account setting. |
| callback_url | string | Where to send the customer afterwards. We add ?reference=…&status=…. |
| reference | string | Your own order ID. Sending the same one again returns the same payment instead of charging twice. |
| description | string | Shown to the customer. |
For safe retries you can also send an Idempotency-Key header.
curl -X POST https://www.lynkinfra.com/api/v1/pay/payments \
-H "Authorization: Bearer sk_live_your_key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042" \
-d '{
"amount": 10000,
"channel": "MOBILE_MONEY",
"email": "customer@example.com",
"fee_bearer": "customer",
"callback_url": "https://yourshop.com/orders/1042/paid"
}'{
"status": true,
"message": "Payment initialized",
"data": {
"reference": "LNK-3F9A1C2B7D4E",
"authorization_url": "https://…",
"amount": 10304,
"fee": 304,
"net_amount": 10000,
"fee_bearer": "customer"
}
}amount is what the customer pays, fee is Lynk Pay's fee, and net_amount is what you receive. Redirect the customer to authorization_url.
GET/pay/payments/{reference}
Always confirm a payment on your server before delivering an order — don't rely on the redirect alone. Use Lynk's LNK-… reference or the reference you sent when creating it.
curl https://www.lynkinfra.com/api/v1/pay/payments/LNK-3F9A1C2B7D4E \
-H "Authorization: Bearer sk_live_your_key"{
"status": "success",
"reference": "LNK-3F9A1C2B7D4E",
"amount": 10304,
"fee": 304,
"net_amount": 10000,
"fee_bearer": "customer",
"currency": "GHS",
"refunded_amount": 0,
"refundable_amount": 10000
}status is pending, success, failed, cancelled or refunded. Mobile Money can take a minute or two while the customer approves.
POST/pay/payments/{reference}/refund
Sends money back to your customer for a successful payment — all of it or part. The customer gets back what they paid, less any fee they covered (fees aren't refundable). Live refunds are taken from your Lynk wallet, so keep it topped up.
| Field | Type | Description |
|---|---|---|
| amount | integer | In pesewas. Leave out to refund everything still refundable (see refundable_amount). |
| reason | string | Shown to the customer. |
curl -X POST https://www.lynkinfra.com/api/v1/pay/payments/LNK-3F9A1C2B7D4E/refund \
-H "Authorization: Bearer sk_live_your_key" \
-H "Content-Type: application/json" \
-d '{"amount": 5000, "reason": "Booking cancelled"}'{
"status": true,
"message": "Refund started",
"data": { "id": "cm…", "reference": "LNK-3F9A1C2B7D4E", "amount": 5000, "currency": "GHS", "status": "pending" }
}Refunds usually complete within a few business days; you get a payment.refunded webhook when it's done, or refund.failed (and the money returns to your wallet). Errors: 402 INSUFFICIENT_FUNDS (top up your wallet), 409 REFUND_NOT_ALLOWED (e.g. more than the refundable amount), 404 NOT_FOUND.
Add your webhook URL in Lynk Pay → Developers (it must be a public https:// address). We POST these events:
| Field | Type | Description |
|---|---|---|
| payment.success | event | The customer paid. Deliver the order. |
| payment.failed | event | The payment didn't go through. |
| payment.refunded | event | A refund reached the customer. |
| refund.failed | event | A refund couldn't be completed; the money is back in your wallet. |
{
"event": "payment.success",
"data": {
"reference": "LNK-3F9A1C2B7D4E",
"amount": 10304,
"fee": 304,
"net_amount": 10000,
"fee_bearer": "customer",
"currency": "GHS",
"status": "success",
"customer": { "email": "customer@example.com" }
}
}Check the signature on every webhook: X-Lynk-Signature is the HMAC-SHA256 (hex) of the raw request body, using your webhook secret. Reply with any 2xx within 10 seconds. If you don't, we send it again — so skip any X-Lynk-Delivery id you've already handled.
import crypto from "node:crypto";
import express from "express";
const app = express();
const seen = new Set(); // use your database in production
app.post("/webhooks/lynk", express.raw({ type: "application/json" }), (req, res) => {
const expected = crypto.createHmac("sha256", process.env.LYNK_WEBHOOK_SECRET).update(req.body).digest("hex");
const given = req.get("X-Lynk-Signature") ?? "";
if (given.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
return res.sendStatus(401);
}
const delivery = req.get("X-Lynk-Delivery");
if (!seen.has(delivery)) {
seen.add(delivery);
const { event, data } = JSON.parse(req.body.toString());
if (event === "payment.success") {
// Mark the order with data.reference as paid.
}
}
res.sendStatus(200);
});<?php
$body = file_get_contents("php://input");
$expected = hash_hmac("sha256", $body, getenv("LYNK_WEBHOOK_SECRET"));
if (!hash_equals($expected, $_SERVER["HTTP_X_LYNK_SIGNATURE"] ?? "")) {
http_response_code(401);
exit;
}
$payload = json_decode($body, true);
if ($payload["event"] === "payment.success") {
// Mark the order with $payload["data"]["reference"] as paid.
}
http_response_code(200);POST/products/importsneeds products
Your app's user gives their products in any form: a spreadsheet (CSV or Excel), a PDF price list, a Word file, a photo of a notebook or shelf, a typed list, or their voice. Lynk AI arranges them into your product details, asks about what's missing or unclear, and hands back clean products for your app to save. Your users never sign in to Lynk: they're known by your own id for them, and pay with Lynk credit they buy inside your app.
Send JSON (files as base64), or a form upload with the JSON in a data part and files under file. Spreadsheets and short lists are read before the reply; PDFs and photos after it (status reading: poll the import or wait for your webhook).
| Field | Type | Description |
|---|---|---|
| user* | object | {id, name?, email?}: your own id for this user (a business on your app, say). |
| files | array | [{name, type?, data}], data in base64. Up to 10 files, 20 MB each. Or send text. |
| text | string | Their products in their own words, or a pasted list. |
| details | array | Your product details: [{key, label?, kind?, required?, role?}]. Kinds: text, money, number, date, yesno. Roles (what it means, guessed from the key when left out): name, price, cost, wholesale, quantity, category, unit, sku, barcode, brand, supplier, description, reorder, expiry, batch. Without details, Lynk's standard set is used. |
| lists | object | {categories?, units?, shops?: [{id, name}]}: your own lists, so Lynk reuses your spellings and asks which shop quantities are for. |
| existing | array | [{id?, name?, sku?, barcode?}]: products you already have, so Lynk spots them. |
| variants | boolean | Bring colours and sizes in as one product with its choices (each with its own price, quantity, code and barcode). |
| can_update | boolean | Your app can update products it already has (otherwise those are left out). |
| billing | string | user (default: the user's Lynk credit) or app (your Lynk balance). |
| screen | boolean | Also make Lynk's ready-made screen: open the returned screen_url inside your app (a frame or a window). With no file or text, the user gives them there. |
| webhook_url, check_url | string | Override your app's addresses (Developer settings → Product import) for this import. |
| look | object | {name?, color?, logo_url?}: your app's name, colour and logo on the screen. |
curl -X POST https://www.lynkinfra.com/api/v1/products/imports \
-H "Authorization: Bearer lynk_your_key" \
-H "Content-Type: application/json" \
-d '{"user": {"id": "tenant-42", "name": "Glow Mart"}, "screen": true, "variants": true,
"details": [{"key": "name", "label": "Product Name", "required": true},
{"key": "sellingPrice", "label": "Selling Price", "kind": "money"},
{"key": "costPrice", "label": "Cost Price", "kind": "money"},
{"key": "quantityOnHand", "label": "Stock", "kind": "number"},
{"key": "categoryName", "label": "Category"}],
"lists": {"categories": ["Drinks", "Provisions"], "shops": [{"id": "s1", "name": "Osu"}]}}'{
"id": "cm…", "status": "waiting",
"screen_url": "https://www.lynkinfra.com/import/…", "screen_expires_at": "…",
"user": { "id": "tenant-42", "credit_cents": 2000 },
"message": "Send me your products: a spreadsheet, a PDF price list…",
"questions": [], "products": [], "cost_cents": 0, "currency": "GHS"
}Your page hears from the screen by postMessage: {source: "lynk-import", event: "ready" | "finished" | "closed" | "credit_added"}, with result on finished. Each status: waiting, reading, asking (questions open), ready, needs_credit, finished, failed, cancelled. GET /products/imports/{id} reads one; DELETE cancels it.
Each import has questions (the first is next_question), each with options to tap, and products (each with its row id, values in your details, status and any choices). Lynk never makes a value up: a missing price stays missing until the user gives it.
POST/products/imports/{id}/answersneeds products
| Field | Type | Description |
|---|---|---|
| option | object | {question, option}: a tapped answer (free). |
| text | string | Their own words, typed or spoken: Indomie is 8 cedis, remove the last three, the cost is 70% of the price. Lynk AI understands them (paid). |
| cells | array | [{row, key, value, choice?}]: values changed in the table (free). |
| remove, restore | array | Row ids to remove, or bring back. |
POST/products/imports/{id}/filesneeds products
Another file or more words for an open import.
POST/products/imports/{id}/finishneeds products
Returns result.products (each {row, action: add | update, match?, values, choices?}) and result.left_out (with why), and sends the same to your webhook. If your check finds something new first, the import stays open with that question (finished: false).
POST/products/imports/{id}/screenneeds products
A new ready-made screen address for the import (good for 6 hours).
Lynk never reads your database. Set two addresses in Developer settings → Product import, and Lynk signs everything it sends to them (X-Lynk-Signature: the hex HMAC-SHA256 of the raw body with your signing secret).
Check address (import.check): after reading, and again before finishing, Lynk sends the rows so your app can check them against its own database, like your import preview. Answer within 15 seconds with {results: [{row, status: add | skip | error, reason?, warnings?}]}. Each finding becomes a question for the user ("ABS says “Milo” can't be added: SKU M1 is already used"). Without a check address, send the same with POST /products/imports/{id}/check.
Webhook (import.finished): the finished products, as returned by finish. Reply 2xx; unanswered messages are sent again (1, 5, 15 and 60 minutes, then every 3 hours, 10 tries).
{
"event": "import.finished",
"import_id": "cm…", "user": { "id": "tenant-42" }, "shop_id": "s1",
"products": [
{ "row": "r1", "action": "add", "values": { "name": "Polo shirt", "sellingPrice": 50, "quantityOnHand": 5 },
"choices": [ { "attributes": { "colour": "Red", "size": "XL" }, "values": { "sellingPrice": 50, "quantityOnHand": 3 } },
{ "attributes": { "colour": "Blue", "size": "M" }, "values": { "sellingPrice": 50, "quantityOnHand": 2 } } ] }
],
"left_out": [ { "row": "r7", "name": "Sugar 1kg", "why": "Already in your products" } ],
"cost_cents": 110, "currency": "GHS"
}The AI work is charged as it happens, at what it costs plus Lynk's markup, from the user's Lynk credit (or your balance with billing: app). Big files show an estimate first. Tapped answers and table changes are free.
GET/app-users/{id}needs products
A user's credit and its last changes (your own id for them).
POST/app-users/{id}/credit/buyneeds products
{amount_cents} (GH₵5 to GH₵5,000): returns payment_url, Lynk's payment page (mobile money or card) to open in your app. Once paid, the credit is on their balance.
POST/app-users/{id}/creditneeds products
{give_cents}: give your user credit from your own Lynk balance (a welcome gift, or credit you sold them).
Need help? Contact support with the endpoint, status code and error message — never your API key.