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 (e.g. no approved payout account, or that payment method is off). |
| 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 an OpenAI 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.
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);Need help? Contact support with the endpoint, status code and error message — never your API key.