Developers

Lynk API

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

Getting started

All requests go to:

https://www.lynkinfra.com/api/v1
  1. Create a free Lynk account.
  2. Messaging and AI: create a key in Developer / API. Payments: create keys in Lynk Pay → Developers.
  3. Send JSON with Content-Type: application/json and your key in the Authorization header.
Your first request
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"}'

Authentication

Send your key as a Bearer token. Keep keys on your server — never in a website or mobile app.

Authorization: Bearer <your key>
KeyLooks likeUsed for
Account keylynk_…SMS, email, WhatsApp and Lynk AI
Lynk Pay secret keysk_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.

Errors

Every error has an HTTP status and a message you can show or log.

StatusMeaning
400 / 422Something in the request is missing or invalid.
401The API key is missing or wrong.
402Not enough wallet balance or messaging allowance. Top up and try again.
403The key doesn't have permission for this service.
404Not found (e.g. a payment reference).
409Lynk Pay can't take this payment yet — see the code: PAYOUT_ACCOUNT_REQUIRED, DUPLICATE_REFERENCE, METHOD_UNAVAILABLE, PAYMENT_PENDING.
503The service is temporarily unavailable. Try again later.
500 / 502Something went wrong on our side. Retry with backoff.

Error bodies by API:

Messaging and /ai
{ "error": "Insufficient wallet balance" }
Chat API endpoints
{ "error": { "message": "…", "type": "invalid_request_error", "code": "model_not_found" } }
Lynk Pay
{ "status": false, "message": "…", "code": "VALIDATION_ERROR" }

Send SMS

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.

FieldTypeDescription
to*string | string[]A number or a list. Ghana numbers like 0241234567 or +233241234567; other countries with their country code.
message*stringThe text. Up to 6 parts.
senderstringYour approved sender ID. Without it, Lynk's default sender is used.
Request
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"}'
Response
{
  "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.

Send email

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.

FieldTypeDescription
to*stringRecipient email address.
subject*stringSubject line.
body*stringPlain-text message.
Request
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!"}'
Response
{ "id": "cm…", "status": "SENT", "unitsUsed": 1 }

Send WhatsApp

POST/whatsappneeds whatsapp

Sends a WhatsApp message, charged per message from your wallet. Returns 503 while WhatsApp isn't available on your account.

FieldTypeDescription
to*stringPhone number with country code.
message*stringThe text.
Response
{ "id": "cm…", "status": "SENT", "costCents": 12 }

Lynk AI: chat

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.

FieldTypeDescription
promptstringA single question. Send this or messages.
messagesobject[]A conversation: [{ "role": "user" | "assistant", "content": "…" }], ending with the user. Up to 100 messages.
modestringAUTO (default), QUALITY, FAST, ECONOMY or PRIVACY.
Request
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"}'
Response
{
  "id": "cm…",
  "completion": "Weekend sale! 20% off everything…",
  "model": "…",
  "usage": { "inputTokens": 18, "outputTokens": 42 },
  "charged": { "amountCents": 1 },
  "privacy": { "protectedCount": 0 }
}

Lynk AI: works with standard chat libraries

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:

FieldTypeDescription
lynk-automodelBest model for each request (default).
lynk-qualitymodelThe most capable available model.
lynk-fastmodelThe quickest available model.
lynk-economymodelThe lowest-cost available model.
lynk-privacymodelOnly privacy-approved models.
Node.js
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);
Python
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.

Lynk AI: images

POST/images/generationsneeds images

Creates one image per request, returned as base64. Charged per image.

FieldTypeDescription
prompt*stringWhat to draw. Up to 4,000 characters.
modelstringlynk-auto (default) or lynk-quality.
sizestring1024x1024 (default), 1024x1536 or 1536x1024.
Request
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"}'
Response
{
  "created": 1790000000,
  "data": [{ "b64_json": "iVBORw0KGgo…" }],
  "output_format": "png",
  "lynk": { "image_id": "cm…", "charged_cents": 5 }
}

Lynk Pay: create a payment

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.

FieldTypeDescription
amount*integerWhat you want to receive, in pesewas.
channel*stringMOBILE_MONEY or CARD.
email*stringCustomer's email, for their receipt.
currencystringGHS (the only currency for now).
fee_bearerstringmerchant (fee comes out of your payout) or customer (added on top). Defaults to your account setting.
callback_urlstringWhere to send the customer afterwards. We add ?reference=…&status=….
referencestringYour own order ID. Sending the same one again returns the same payment instead of charging twice.
descriptionstringShown to the customer.

For safe retries you can also send an Idempotency-Key header.

Request
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"
  }'
Response
{
  "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.

Lynk Pay: check a payment

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.

Request
curl https://www.lynkinfra.com/api/v1/pay/payments/LNK-3F9A1C2B7D4E \
  -H "Authorization: Bearer sk_live_your_key"
Response
{
  "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.

Lynk Pay: refunds

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.

FieldTypeDescription
amountintegerIn pesewas. Leave out to refund everything still refundable (see refundable_amount).
reasonstringShown to the customer.
Request
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"}'
Response
{
  "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.

Lynk Pay: webhooks

Add your webhook URL in Lynk Pay → Developers (it must be a public https:// address). We POST these events:

FieldTypeDescription
payment.successeventThe customer paid. Deliver the order.
payment.failedeventThe payment didn't go through.
payment.refundedeventA refund reached the customer.
refund.failedeventA refund couldn't be completed; the money is back in your wallet.
payment.success body
{
  "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.

Node.js (Express)
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
<?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);

Product import: start

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

FieldTypeDescription
user*object{id, name?, email?}: your own id for this user (a business on your app, say).
filesarray[{name, type?, data}], data in base64. Up to 10 files, 20 MB each. Or send text.
textstringTheir products in their own words, or a pasted list.
detailsarrayYour 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.
listsobject{categories?, units?, shops?: [{id, name}]}: your own lists, so Lynk reuses your spellings and asks which shop quantities are for.
existingarray[{id?, name?, sku?, barcode?}]: products you already have, so Lynk spots them.
variantsbooleanBring colours and sizes in as one product with its choices (each with its own price, quantity, code and barcode).
can_updatebooleanYour app can update products it already has (otherwise those are left out).
billingstringuser (default: the user's Lynk credit) or app (your Lynk balance).
screenbooleanAlso 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_urlstringOverride your app's addresses (Developer settings → Product import) for this import.
lookobject{name?, color?, logo_url?}: your app's name, colour and logo on the screen.
Request
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"}]}}'
Response (shortened)
{
  "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.

Product import: questions, answers and finishing

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

FieldTypeDescription
optionobject{question, option}: a tapped answer (free).
textstringTheir 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).
cellsarray[{row, key, value, choice?}]: values changed in the table (free).
remove, restorearrayRow 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).

Product import: your app's check and webhook

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

Webhook body
{
  "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"
}

Product import: your users' Lynk credit

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

Limits

  • SMS: up to 1,000 recipients and 6 parts per request.
  • AI chat: up to 100 messages per request, each up to 20,000 characters.
  • AI images: one image per request, prompt up to 4,000 characters.
  • Request bodies are JSON; amounts are in the smallest currency unit (pesewas).

Need help? Contact support with the endpoint, status code and error message — never your API key.