Developers / Messages and OTP API

Messages and OTP API

Send SMS, WhatsApp and email with one call, with fallback chains, idempotency, one-time codes and delivery webhooks.

7 min read
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" }
  }'
FieldRequiredMeaning
channelNosms (the default), whatsapp, email or auto
toFor SMS and WhatsAppThe phone number. SMS goes to Nigerian numbers only
emailFor emailThe email address
bodyUnless template is setThe message. {{field}} tags fill from variables
variablesNoAn object of values for the {{field}} tags in body
templateFor WhatsAppThe name of an approved WhatsApp template
subjectNoThe email subject
typeNotransactional (the default), promotional or otp
sender_idNoWhich approved sender ID to use. Leave it out to use your most recently updated one for this type of message
fallbackNoA chain of channels. See the next section
callback_urlNoWhere to send this message's delivery reports
dry_runNotrue 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 statusMeaning
activeThe chain is still running
sentThe last channel took the message, with no delivery confirmed
deliveredDelivery was confirmed
failedNo 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.
  • after is how long the next channel waits for the one before it to deliver. Write 10m or 2h. 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 template and 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 statusMeaning
queuedWaiting to send, with credit held
sendingBeing handed to the network
submittedHanded over with no clear answer yet. It will not be sent twice
acceptedTaken for delivery. This is when it is charged
sentPassed to the network
deliveredThe network confirmed delivery
failed, expiredThe network could not deliver it
no_receiptNo final answer arrived in time. It may have arrived
rejected, blocked, cancelledIt 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 }'
FieldRequiredMeaning
toYesA Nigerian mobile number
brandNoYour name, shown at the start of the text
lengthNoDigits in the code, 4 to 8. The default is 6
expires_inNoSeconds 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 statusMeaning
pendingThe code was wrong, or not yet tried. The customer can try again
approvedThe code matched. Let the customer through
expiredThe time ran out. Send a new code
failedToo 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.

HTTPcode on /v1/otpMeaning
401The key is missing, invalid, revoked or expired
402INSUFFICIENT_FUNDSThe wallet balance is too low
403NOT_ENABLEDSMS is off or paused for this workspace, or the wallet has never been funded
403The key lacks the messages scope, or the feature needs a higher plan
404The message or verification was not found
422INVALID_NUMBERThis is not a valid mobile number
422NO_SENDERNo approved sender ID for this type of message
422OPTED_OUT, DND, NOT_ALLOWEDThis number opted out, is on Do Not Disturb, or reached its daily promotional limit
422SAMPLE_CONTACTThis number belongs to your sample data
422CONTENT, OPT_OUT, ROUTE, EMPTYA content rule stopped the message. The error says which and how to fix it
429RATE_LIMITThe workspace reached its hourly sending limit
429FRAUD_NUMBER, FRAUD_PREFIX, FRAUD_WORKSPACE, FRAUD_COUNTRYThe OTP shield stopped the request
429Too many API requests. See below
503NO_PRICEThis message type has no price set yet

What are the rate limits?

LimitDefaultApplies to
API requests300 every 15 minutesEach workspace, across all /v1 calls
Messages sent50,000 an hourEach workspace
OTP per number5 an hourEach number
OTP per number range60 a minuteEach number prefix
OTP per workspace300 a minuteEach workspace
OTP countriesNigeriaEach 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.