Messages and OTP API
Send SMS, WhatsApp and email with one call, with fallback chains, idempotency, one-time codes and delivery webhooks.
On this page
One API for SMS, WhatsApp and email, on the same API keys as the rest of QuiQSol.
Base URL: https://api.quiqsol.com/v1. Bodies are JSON.
How do I authenticate?
Send your key in the Authorization header on every request. Create keys in Settings, Developer. See Authentication.
Authorization: Bearer <your-api-key>
A key created by hand has full access. A key minted for an integration carries scopes, and it needs the messages scope to call this API. Without it the answer is 403.
How do I send a message?
POST /v1/messages sends on one channel, or on a chain of channels.
curl -X POST https://api.quiqsol.com/v1/messages \
-H "Authorization: Bearer <your-api-key>" \
-H "Idempotency-Key: repay-88213-nov" \
-H "Content-Type: application/json" \
-d '{
"channel": "sms",
"to": "+2348031234567",
"type": "transactional",
"body": "Hi {{name}}, your repayment of N45,000 is due on Friday.",
"variables": { "name": "Chiamaka" }
}'
| Field | Required | Meaning |
|---|---|---|
channel | No | sms (the default), whatsapp, email or auto |
to | For SMS and WhatsApp | The phone number. SMS goes to Nigerian numbers only |
email | For email | The email address |
body | Unless template is set | The message. {{field}} tags fill from variables |
variables | No | An object of values for the {{field}} tags in body |
template | For WhatsApp | The name of an approved WhatsApp template |
subject | No | The email subject |
type | No | transactional (the default), promotional or otp |
sender_id | No | Which approved sender ID to use. Leave it out to use your most recently updated one for this type of message |
fallback | No | A chain of channels. See the next section |
callback_url | No | Where to send this message's delivery reports |
dry_run | No | true runs the checks and prices the message without sending |
The answer is 202 with the request and its chain:
{
"id": "msg_6729f0c2a1b3c4d5e6f70812",
"status": "active",
"to": "+2348031234567",
"chain": [
{ "channel": "sms", "status": "sent", "after_minutes": 0 }
],
"created_at": "2026-11-06T09:30:00.000Z"
}
Request status | Meaning |
|---|---|
active | The chain is still running |
sent | The last channel took the message, with no delivery confirmed |
delivered | Delivery was confirmed |
failed | No channel could take the message |
Each step in chain is waiting, sent, delivered, failed or skipped, and a step that did not go carries an error with the reason.
How does the fallback chain work?
List the channels in order. When fallback is present it sets the chain, and channel is ignored.
{
"to": "+2348031234567",
"template": "repayment_reminder",
"body": "Your repayment of N45,000 is due on Friday.",
"fallback": [
{ "channel": "whatsapp" },
{ "channel": "sms", "after": "10m" }
]
}
- The first step runs at once.
afteris how long the next channel waits for the one before it to deliver. Write10mor2h. A bare number means minutes.- If a step has not delivered by then, the next channel takes over. If it was delivered, the chain stops there.
- If a step fails or is skipped, the next channel starts straight away with no wait.
- A WhatsApp step needs an approved
templateand a connected WhatsApp number. Without them it is skipped and the chain moves on.
"channel": "auto" chooses from what each contact has responded to. A contact who responds best on SMS gets SMS. Everyone else gets WhatsApp first, then SMS after 10 minutes.
Chains of more than one channel and auto are part of SMS Business and Quiq Growth. On other plans the answer is 403.
A single SMS or email step that fails answers with its error. A step that is skipped, or a chain where every step fails, answers 202 with status set to failed and the reason on each step, so always read status as well as the HTTP code.
How do I test a send without sending?
Add "dry_run": true. Nothing is sent, charged or held. The answer is 200 when the message would send and 422 when it would be refused, and blocked_by says why.
{
"dry_run": true,
"would_send": true,
"blocked_by": [],
"to": "+2348031234567",
"network": "MTN",
"encoding": "gsm7",
"segments": 1,
"cost": { "estimated": 1, "currency": "NGN" }
}
A dry run checks the message itself, and does not check that your wallet holds enough to pay for it. Amounts in the examples on this page show the shape of the answer and are not prices.
How do I read a message back?
GET /v1/messages/msg_<id> returns the request, with the SMS itself under sms once one exists.
{
"id": "msg_6729f0c2a1b3c4d5e6f70812",
"status": "delivered",
"to": "+2348031234567",
"chain": [{ "channel": "sms", "status": "delivered", "after_minutes": 0 }],
"created_at": "2026-11-06T09:30:00.000Z",
"sms": {
"id": "msg_6729f0c2a1b3c4d5e6f70813",
"channel": "sms",
"status": "delivered",
"to": "+2348031234567",
"network": "MTN",
"segments": 1,
"encoding": "gsm7",
"cost": { "charged": 1, "reserved": 0, "refunded": 0, "currency": "NGN" },
"failure": null,
"created_at": "2026-11-06T09:30:00.000Z"
}
}
SMS status | Meaning |
|---|---|
queued | Waiting to send, with credit held |
sending | Being handed to the network |
submitted | Handed over with no clear answer yet. It will not be sent twice |
accepted | Taken for delivery. This is when it is charged |
sent | Passed to the network |
delivered | The network confirmed delivery |
failed, expired | The network could not deliver it |
no_receipt | No final answer arrived in time. It may have arrived |
rejected, blocked, cancelled | It was never sent and never charged |
Promotional messages sent during quiet hours stay queued until quiet hours end.
How do I send and check a one-time code?
POST /v1/otp generates a code and texts it. You never see the code, and QuiQSol stores only a hash of it.
curl -X POST https://api.quiqsol.com/v1/otp \
-H "Authorization: Bearer <your-api-key>" \
-H "Idempotency-Key: login-88213-1" \
-H "Content-Type: application/json" \
-d '{ "to": "+2348031234567", "brand": "Kudi Plus", "length": 6, "expires_in": 300 }'
| Field | Required | Meaning |
|---|---|---|
to | Yes | A Nigerian mobile number |
brand | No | Your name, shown at the start of the text |
length | No | Digits in the code, 4 to 8. The default is 6 |
expires_in | No | Seconds until it expires, up to 1800. The default is 300 |
{
"verification_id": "ver_6729f0c2a1b3c4d5e6f70814",
"status": "pending",
"expires_at": "2026-11-06T09:35:00.000Z"
}
POST /v1/otp/verify checks what your customer typed.
curl -X POST https://api.quiqsol.com/v1/otp/verify \
-H "Authorization: Bearer <your-api-key>" \
-H "Content-Type: application/json" \
-d '{ "verification_id": "ver_6729f0c2a1b3c4d5e6f70814", "code": "482913" }'
{
"verification_id": "ver_6729f0c2a1b3c4d5e6f70814",
"status": "approved",
"attempts": 1
}
Always read status, because a wrong code is still a 200.
OTP status | Meaning |
|---|---|
pending | The code was wrong, or not yet tried. The customer can try again |
approved | The code matched. Let the customer through |
expired | The time ran out. Send a new code |
failed | Too many wrong tries, 5 by default. Send a new code |
Any verification fee on your plan is taken only when a code is approved.
How do retries stay safe?
Send an Idempotency-Key header on POST /v1/messages and POST /v1/otp, up to 200 characters. The same key returns the first result with a 200 and never sends again, so you can retry after a timeout without texting anyone twice. Use a new key for each new message.
POST /v1/otp/verify and dry runs do not use the key, and neither needs it.
How do delivery webhooks work?
Add an endpoint in Quiq SMS, Developers, using a public https URL. Registered endpoints are part of SMS Business and Quiq Growth. Choose message.delivered, message.failed or both. The signing secret is shown once, when you add the endpoint, so copy it then.
Each report is a JSON POST:
{
"event": "message.delivered",
"id": "6729f0c2a1b3c4d5e6f70813",
"to": "+2348031234567",
"network": "MTN",
"status": "DELIVERED",
"segments": 1,
"failure": null,
"refunded": false,
"at": "2026-11-06T09:30:12.000Z"
}
id here has no msg_ prefix and status is in capitals. GET /v1/messages/:id accepts the id with or without the prefix.
Two headers come with it: x-quiq-event names the event, and x-quiq-signature is the HMAC SHA-256 of the raw body, in hex. Check the signature before trusting a report.
const crypto = require('crypto');
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const valid = expected.length === signature.length
&& crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
Answer with any 2xx within 6 seconds. Anything else is retried, up to 8 attempts in all, waiting twice as long each time and starting at 2 minutes.
A callback_url on a message gets the same report for that message. Its signing secret is not shown in the console, so use a registered endpoint whenever you need to verify the signature.
What errors can I get?
Errors are JSON with a plain error message you can show or log. POST /v1/otp also includes a code when it has one.
| HTTP | code on /v1/otp | Meaning |
|---|---|---|
401 | The key is missing, invalid, revoked or expired | |
402 | INSUFFICIENT_FUNDS | The wallet balance is too low |
403 | NOT_ENABLED | SMS is off or paused for this workspace, or the wallet has never been funded |
403 | The key lacks the messages scope, or the feature needs a higher plan | |
404 | The message or verification was not found | |
422 | INVALID_NUMBER | This is not a valid mobile number |
422 | NO_SENDER | No approved sender ID for this type of message |
422 | OPTED_OUT, DND, NOT_ALLOWED | This number opted out, is on Do Not Disturb, or reached its daily promotional limit |
422 | SAMPLE_CONTACT | This number belongs to your sample data |
422 | CONTENT, OPT_OUT, ROUTE, EMPTY | A content rule stopped the message. The error says which and how to fix it |
429 | RATE_LIMIT | The workspace reached its hourly sending limit |
429 | FRAUD_NUMBER, FRAUD_PREFIX, FRAUD_WORKSPACE, FRAUD_COUNTRY | The OTP shield stopped the request |
429 | Too many API requests. See below | |
503 | NO_PRICE | This message type has no price set yet |
What are the rate limits?
| Limit | Default | Applies to |
|---|---|---|
| API requests | 300 every 15 minutes | Each workspace, across all /v1 calls |
| Messages sent | 50,000 an hour | Each workspace |
| OTP per number | 5 an hour | Each number |
| OTP per number range | 60 a minute | Each number prefix |
| OTP per workspace | 300 a minute | Each workspace |
| OTP countries | Nigeria | Each workspace |
Answers carry RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset headers for the API request limit. See Rate limits for the rest of the platform.