- Create an account, then a sandbox key in Dashboard → API keys, and export it: export ENVOISMS_API_KEY=env_test_…
- Paste one of the samples alongside: it runs as is with that key, sending and charging nothing.
- Register a webhook to receive delivery statuses and replies.
- Top up, create a live key (smr_…) and swap it for the sandbox key in your configuration.
1. Send an SMS
One call. With a sandbox key (env_test_…) the send is simulated: nothing leaves the platform, nothing is charged, and the response shows the price a live key would pay (simulated_cost).
ResponseHTTP/1.1 202 Accepted
{
"id": "msg_9f2c4e1a7b3d",
"to": "+212612345678",
"channel": "sms",
"cascade": false,
"status": "queued",
"cost": { "eur": 0, "mad": 0 },
"sandbox": true,
"simulated_cost": { "eur": 0.0704, "mad": 0.792 },
"segments": 1,
"created_at": "2026-10-22T10:30:00.000Z"
}curl https://api.envoisms.ma/v1/messages \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"to": "+212612345678",
"message": "Your order #1042 has shipped."
}'2. Send a WhatsApp template
WhatsApp sends from your own WhatsApp Business number, connected once in the dashboard (otherwise 403 WHATSAPP_NOT_CONNECTED). Outside the 24-hour window only an approved template can open the conversation; its {{1}}, {{2}}… variables go in metadata.variables, in order.
ResponseHTTP/1.1 202 Accepted
{
"id": "msg_4b81d0c2e9f7",
"to": "+212612345678",
"channel": "whatsapp",
"cascade": false,
"status": "queued",
"cost": { "eur": 0.05778, "mad": 0.65 },
"segments": 1,
"created_at": "2026-10-22T10:31:00.000Z"
}curl https://api.envoisms.ma/v1/messages \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+212612345678",
"channel": "whatsapp",
"template": { "name": "order_shipped", "language": "fr" },
"metadata": { "variables": ["Karim", "#1042"] }
}'3. Verify a phone number (OTP)
EnvoiSMS generates the code, delivers it, counts attempts and expires it (10 minutes by default). You store only session_id. With a sandbox key the response carries sandbox_code, so you can test /verify/check without a phone.
ResponsePOST /v1/verify/send → 202 Accepted
{
"session_id": "vrf_2c7e9a14b0d3",
"message_id": "msg_e1a07c93b5f2",
"to": "+212612345678",
"channel": "sms",
"expires_at": "2026-10-22T10:42:00.000Z",
"resend_after_seconds": 30,
"fallback_channels": [],
"status": "sent",
"sandbox": true,
"simulated": true,
"sandbox_code": "483921"
}
POST /v1/verify/check → 200 OK
{ "session_id": "vrf_2c7e9a14b0d3", "verified": true, "verified_at": "2026-10-22T10:33:12.000Z" }# 1. Send the code
curl https://api.envoisms.ma/v1/verify/send \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "to": "+212612345678", "brand": "MyApp" }'
# 2. Check what the user typed
curl https://api.envoisms.ma/v1/verify/check \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "session_id": "vrf_…", "code": "483921" }'Authentication
Authentication#
Every /v1 request carries an API key in the Authorization header. Create, restrict and revoke keys in Dashboard → API keys; a key is shown once, when it is created. Use it server-side only.
smr_… really sends and draws on the balance; env_test_… simulates everything, free. Same URL for both.
Each key carries a list of permissions (send, verify, status…). Outside that list: 403 FORBIDDEN.
Optional, per key. A call from any other IP gets 401 UNAUTHORIZED.
X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix time) on every response.
POST /v1/messages HTTP/1.1
Host: api.envoisms.ma
Authorization: Bearer smr_your_live_key
Content-Type: application/json
Idempotency-Key: 6f1d2c9e-4b7a-4f3e-9d21-0c5e8a7b3f10
HTTP/1.1 202 Accepted
X-RateLimit-Limit: 180
X-RateLimit-Remaining: 179
X-RateLimit-Reset: 1792663860Order confirmation (SMS)
Send an order confirmation by SMS#
Updated
- Create a sandbox key and export it: export ENVOISMS_API_KEY=env_test_…
- Optional: request your sender name (POST /v1/sender-ids, 15 to 30 days). Until then, omit sender_id: the default sender applies.
- On your "order paid" event, send the SMS with Idempotency-Key = the order id: a hook that fires twice sends one message.
- Register a webhook for message.delivered and message.failed; metadata.order_id comes back in every delivery.
- Try +212600000002 to see the failure branch, then switch to a live key (smr_…).
curl https://api.envoisms.ma/v1/messages \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042-paid" \
-d '{
"to": "+212612345678",
"message": "Commande #1042 confirmée : 349 MAD. Livraison prévue jeudi.",
"metadata": { "order_id": "1042" }
}'COD orders on WhatsApp
Confirm cash-on-delivery orders on WhatsApp#
Updated
On WooCommerce the EnvoiSMS plugin (v2.0.0) does all of it: each COD order waits, the customer receives a message with Confirm / Cancel buttons, and their tap updates the order.
- Connect your WhatsApp number in Dashboard → WhatsApp (you can keep the WhatsApp Business app on your phone).
- Install the plugin from github.com/envoisms/envoisms-woocommerce (WooCommerce 7.1+, PHP 8.0+) and paste your API key in WooCommerce → EnvoiSMS Maroc.
- Tick COD "Activer" and save: the plugin registers its webhook (message.inbound) and keeps the signing secret.
- Click "Créer les modèles WhatsApp" (French and Arabic): EnvoiSMS reviews them, then submits them to Meta; their status shows on the same page.
- Once the templates are approved, place a test cash-on-delivery order. You can follow their status through the API:
curl "https://api.envoisms.ma/v1/templates?channel=whatsapp" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"Marketing campaign (API)
Run a marketing campaign through the API#
Updated
- Create a list: POST /v1/contacts/lists {"name": "Loyal customers"} → lst_…
- Import contacts: POST /v1/contacts/import {"list_id", "contacts": [{"phone", "name"}]}.
- Record each person's proof of consent: POST /v1/consents {"phone", "purpose": "marketing", "status": "granted", "source": "form", "evidence": {…}}.
- Create the campaign: SMS with body (say how to unsubscribe); WhatsApp with template_id, template_variables and "consent_attested": true. scheduled_at schedules it.
- Launch it (POST /v1/campaigns/{id}/send), follow it with GET /v1/campaigns/{id}, pause it with /pause. Opted-out numbers (STOP) are left out automatically.
curl https://api.envoisms.ma/v1/campaigns \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Soldes de novembre",
"channel": "sms",
"list_id": "lst_…",
"body": "-20% ce week-end avec le code NOV20.",
"scheduled_at": "2026-11-07T09:00:00Z"
}'Two-way inbox (webhooks)
A two-way support inbox#
Updated
- Register a webhook for message.inbound (and message.read if you show read receipts).
- Verify the signature, then file the message by number: data.from is the customer, data.channel the channel.
- Read a customer's history with GET /v1/conversations/{phone}.
- Reply with POST /v1/conversations/{phone}/messages. On WhatsApp, free text is possible only within 24 hours of the customer's last message; after that, send a template (template_name) or switch to SMS.
- Mark the thread read with POST /v1/conversations/{phone}/mark-read. A customer STOP arrives as contact.optout and blocks later sends.
curl https://api.envoisms.ma/v1/conversations/+212612345678/messages \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "message": "Bonjour, votre colis part aujourd'"'"'hui." }'Platforms & agencies
Connect your clients' WhatsApp#
Updated
A WhatsApp number cannot be connected through the API: Meta requires the business owner to sign in themselves (Embedded Signup). So each client has its own EnvoiSMS account, which pays for its sends.
- The client creates its account on envoisms.ma (e-mail sign-up).
- It connects its number in Dashboard → WhatsApp by signing in to Meta (Embedded Signup; coexistence with the WhatsApp Business app is possible).
- It creates an API key limited to what you need (e.g. send, status) and hands it to you; you keep it server-side, one per client.
- Check the number can send: GET /v1/whatsapp/waba-connection → readiness.can_send and readiness.blockers.
- Create its templates (POST /v1/templates) and send with its key. Its sends, balance and webhooks stay its own.
curl https://api.envoisms.ma/v1/whatsapp/waba-connection \
-H "Authorization: Bearer $CLIENT_API_KEY"Sandbox & testing
Test without sending or paying#
Updated
Create a sandbox key in Dashboard → API keys (tick "Sandbox"). It starts with env_test_; a live key starts with smr_. Both call the same URL, https://api.envoisms.ma/v1: the key decides, not the address.
A sandbox key is validated exactly like a live key (number format, Sender ID, 24-hour window, content), but nothing is sent and nothing is charged. What a live key would have been refused for is reported in warnings rather than hidden.
- POST /v1/messages returns sandbox: true, a zero cost and simulated_cost: the real price of the same send.
- POST /v1/verify/send returns sandbox_code: the code the user would have received.
- Each key sees only its own kind of traffic: GET /v1/messages with a sandbox key lists only simulated sends, and the other way round.
- Test numbers (POST /v1/messages, sandbox key only): +212600000002 fails (message.failed, SANDBOX_TEST_FAILED); +212600000003 is undeliverable (message.undeliverable); +212600000004 is opted out (403 OPTED_OUT at request time); +212600000005 never gets a receipt (stays "sent"); every other number, +212600000001 included, is delivered (message.delivered) about 2 s after the send.
- A sandbox key cannot be turned into a live one: create a live key when you are ready and swap the environment variable.
SMS or WhatsApp?
Choosing the channel#
Updated
SMS is the default: omit channel. It reaches any phone, with no connected number, no template and no 24-hour window. It is the right choice for a notification or an alert to any customer.
WhatsApp ("channel": "whatsapp") needs your own connected WhatsApp Business number. To write first, or more than 24 hours after the customer's last message, use an approved template; free text is accepted only within 24 hours of a message from the customer (otherwise 400 OUT_OF_24H_WINDOW, nothing charged). WhatsApp is priced by the template's category.
Both at once: "cascade": true tries WhatsApp, then falls back to SMS if WhatsApp refuses, fails or does not confirm delivery within cascade_timeout seconds (120 by default). Only what actually went out is billed.
Verification codes: prefer POST /v1/verify/send, which handles generation, attempts, expiry and resend, over sending your own code through /v1/messages.
Idempotency & retries
Retry without sending twice#
Updated
Add an Idempotency-Key header (up to 255 characters, e.g. a UUID or "order-1042-shipped") to POST /v1/messages and POST /v1/messages/bulk (and their /v1/waba/messages equivalents). If the call times out or your process crashes, send the same request with the same key: the send happens once, and the retry returns the original JSON body with Idempotency-Replayed: true.
- A key is kept for 24 hours, per account. Every response below 500 is stored and replayed, 4xx errors included (e.g. 429 or 402): after a 4xx, fix the cause and use a NEW key.
- The key is bound to the exact body, byte for byte: a retry resends the same body. A different body with the same key — even the same JSON formatted differently — returns 422 IDEMPOTENCY_KEY_REUSED.
- A 5xx or a timeout is not stored: retry with the SAME key, backing off (1 s, 2 s, 4 s…).
- 409 IDEMPOTENCY_IN_FLIGHT: the original request is still running; wait Retry-After and retry with the same key. 422 IDEMPOTENCY_KEY_REUSED: the key was already used with a different body.
- 429 RATE_LIMITED: wait the number of seconds in Retry-After. X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset come with every authenticated response.
Pagination
Paging through lists#
Updated
Lists take limit and offset and return { "data": [...], "limit", "offset", "total" }. For GET /v1/messages: 50 by default, 200 at most, newest first, filterable by status, channel, campaign_id, from_date and to_date. Add format=csv to get the whole filtered set as one file.
curl "https://api.envoisms.ma/v1/messages?status=failed&limit=100&offset=0" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"Key permissions
One key, the least access#
Updated
Every key carries permissions. A call outside them returns 403 FORBIDDEN. Give each server the smallest list that works: an OTP backend needs only verify and status.
- send — POST /v1/messages, /v1/messages/bulk, /v1/templates, /v1/conversations, /v1/lookup
- status — GET /v1/messages, POST /v1/verify/check and the rest of /v1/verify (sessions, apps, lookup, void)
- verify — POST /v1/verify/send, /v1/verify/resend
- balance — GET /v1/billing/balance; billing — the rest of /v1/billing
- contacts (contacts, lists, consents), campaigns, webhooks, optouts, analytics, api_keys, sender_id
- WhatsApp setup (/v1/whatsapp/…): changes — connecting a number, tools, profile, relays — need a dashboard session (an API key gets 403). Only reads are open to keys, e.g. GET /v1/whatsapp/waba-connection.
Versioning & changes
What can change, and how you will know#
Updated
The version is in the path: /v1. Inside /v1 the API only changes additively — new endpoints, optional fields, response fields, error codes, webhook events. Ignore fields you do not know. A breaking change ships as /v2 beside /v1, never in its place.
Removing anything from /v1 is announced at least 12 months ahead: by e-mail to the account owner, in the changelog, and on the route itself, whose every response then carries Deprecation (when it was announced), Sunset (when it stops) and Link to the migration guide. The only exception is a security fix, announced as early as possible. No /v1 route is deprecated today.
Limits per endpoint
Limits, endpoint by endpoint#
Updated
Every route shares the key's per-minute limit (by default Starter 60, Business 180, Pro 500, Enterprise 1,200; X-RateLimit-* on every response; 429 RATE_LIMITED with Retry-After beyond it). Some routes add rules of their own:
- POST /v1/messages/bulk: 10,000 messages per call.
- POST /v1/messages and /bulk: the account's optional daily and monthly quotas (PATCH /v1/billing/quota); beyond them, 429 QUOTA_EXCEEDED until midnight UTC.
- POST /v1/verify/send and /resend: per number, one code every 30 s on SMS and 60 s on WhatsApp (429 OTP_COOLDOWN), 5 per hour and 10 per day (429 OTP_RATE_LIMIT); 200 per day per account; outside Morocco, OTP_COUNTRY_PAUSED when too few codes are confirmed.
- POST /v1/verify/check: 3 attempts per session by default (set per Verify app), then 429 MAX_ATTEMPTS.
- POST /v1/inbound-webhooks/catch/{id} (no key): 120 requests per minute per webhook, 64 KB per request.
- Your webhooks: we wait at most 10 s for your answer, then retry 3 times (1 min, 10 min, 1 h).
Webhooks
Receive statuses and replies#
Updated
Create a webhook with POST /v1/webhooks ({"url": "https://…", "events": ["message.delivered", "message.failed"]}). The response carries the signing secret, shown once; POST /v1/webhooks/{id}/rotate-secret issues a new one. events takes an exact name, a namespace ("message.*") or "*".
Each delivery is a JSON POST {"event", "data", "timestamp"}. For a message status, data is {id, to, channel, status, error_code?, error_message?, metadata?} — metadata is what you sent. Answer 2xx within 10 seconds, then process asynchronously; de-duplicate on data.id + event, since a delivery can arrive twice.
Verify X-EnvoiSMS-Signature-256: t=<seconds>,v1=<hex>: compute HMAC-SHA256(secret, t + "." + raw body), compare it to v1 in constant time, and reject when t is more than 5 minutes old. The older X-EnvoiSMS-Signature header (sha256=<hex> of the body alone, no timestamp) is still sent; prefer the new one, which prevents replay. The event is also in X-EnvoiSMS-Event.
- Retries: a 5xx, a 429 or a network error is retried 3 times (after 1 min, 10 min and 1 h — 4 attempts in all). Any other 4xx is not retried.
- Log and replay: GET /v1/webhooks/logs lists the last 100 attempts; POST /v1/webhooks/logs/{log_id}/replay sends a delivery again, freshly signed. POST /v1/webhooks/{id}/test sends a sample.
- WhatsApp relay to your server (a connected number set to "webhook" mode in the dashboard): messages and statuses arrive in WhatsApp Cloud's native shape, signed with X-Hub-Signature-256: sha256=<hex> = HMAC-SHA256(the number's webhook_secret, raw body). That secret is shown once, when it is created or rotated (rotate_webhook_secret). With no secret nothing is relayed. There is no timestamp: de-duplicate on the message id.
Events
message.sentThe message was handed to the network (carrier or WhatsApp).
message.deliveredThe recipient's phone confirmed receipt.
message.readWhatsApp: the recipient read the message (unless they turned read receipts off).
message.failedThe send failed; error_code and error_message say why (see Delivery failure codes).
message.undeliverableThe network confirmed the message cannot be delivered (number does not exist, expired at the carrier).
message.fallbackCascade: the message moves to the next channel. channel = the new channel, previous_channel = the old one, error_code = the reason.
message.inboundA contact wrote to you (an SMS reply or a WhatsApp message, including a button or list tap).
message.reactionWhatsApp: a contact reacted to one of your messages with an emoji.
contact.optout / contact.optinA contact unsubscribed (STOP) or opted back in. Sends to an unsubscribed number are refused (403 OPTED_OUT).
lead.qualifiedThe WhatsApp qualification bot finished qualifying a lead.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
// Keep the RAW body: the signature is computed over the exact bytes received.
app.post('/webhooks/envoisms', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('X-EnvoiSMS-Signature-256') || '';
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const expected = crypto
.createHmac('sha256', process.env.ENVOISMS_WEBHOOK_SECRET)
.update(`${parts.t}.${req.body}`)
.digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
const valid = parts.v1?.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
if (!fresh || !valid) return res.sendStatus(401);
const { event, data } = JSON.parse(req.body);
res.sendStatus(200); // answer fast…
queue.push({ event, data }); // …then process (de-duplicate on data.id + event)
});POST /webhooks/envoisms HTTP/1.1
Content-Type: application/json
X-EnvoiSMS-Event: message.delivered
X-EnvoiSMS-Signature-256: t=1792663800,v1=5f0c…e9
X-EnvoiSMS-Signature: sha256=a41b…7d
User-Agent: EnvoiSMS-Webhook/1.0 (formerly SMSMaroc)
{
"event": "message.delivered",
"data": {
"id": "msg_9f2c4e1a7b3d",
"to": "+212612345678",
"channel": "sms",
"status": "delivered",
"metadata": { "order_id": "1042" }
},
"timestamp": "2026-10-22T10:30:04.000Z"
}Errors
Errors#
An error returns a 4xx or 5xx status and always the same envelope. Branch on error.code, never on the text: error.message is an English sentence whose wording may improve. Each code below says what to do.
HTTP/1.1 400 Bad Request
{
"error": {
"code": "INVALID_PHONE",
"message": "to must be E.164 format, for example +212612345678",
"docs": "https://envoisms.ma/en/docs#errors"
}
}Authentication and access
UNAUTHORIZEDHTTP 401Authorization header missing, the key unknown or revoked, the caller's IP not on the key's allow-list, or the account not active; error.message says which. Send Authorization: Bearer <key> and check the key in Dashboard → API keys.
FORBIDDENHTTP 403The key lacks the permission for this route (see Key permissions), or the route needs a dashboard session. Add the permission to the key or use another one.
ACCOUNT_SUSPENDEDHTTP 403The account is suspended. Contact support; nothing can be sent until it is reviewed.
WORKSPACE_NOT_FOUNDWORKSPACE_SUSPENDEDAPI_KEY_WORKSPACE_MISMATCHHTTP 404 / 403The workspace specified in the x-workspace-id header was not found, suspended, or conflicts with the workspace bound to this API key.
FORBIDDEN_ASSETHTTP 403That WhatsApp number does not belong to your account. Use a phone_number_id from your own connected numbers.
PAYMENT_NOT_SETTLEDINVALID_CREDENTIALMACHINE_PAYMENTS_DISABLEDHTTP 402 / 400 / 501Machine payments (Authorization: Payment …): the deposit has not settled yet (wait, then retry), the credential does not decode, or the feature is off.
Malformed request
INVALID_JSONHTTP 400The body is not valid JSON. Send Content-Type: application/json and a JSON object.
MISSING_FIELDMISSING_FIELDSINVALID_FIELDMISSING_BODYMISSING_MESSAGEMISSING_URLHTTP 400A required field is missing or out of range; error.message names the field. Fix it and resend.
INVALID_PHONEHTTP 400The number is not E.164. Send + country code then the number, no spaces: +212612345678.
INVALID_URLHTTP 400A URL is not https:// or not valid (webhook, media, button). Use a public https URL.
INVALID_CHANNELCHANNEL_NOT_CONFIGUREDCHANNEL_DISABLEDUNSUPPORTED_CHANNELHTTP 400Unknown or unavailable channel. For /v1/messages channel is sms (default) or whatsapp; for /v1/consents it is whatsapp, sms or any.
INVALID_PURPOSEINVALID_STATUSINVALID_SOURCEINVALID_EVIDENCEINVALID_DATEHTTP 400Consents: purpose = marketing | transactional | otp | service; status = granted | withdrawn; source = api | form | import | dashboard; evidence = a JSON object under 4 KB; dates ISO 8601, recorded_at never in the future.
INVALID_PERMISSIONSSANDBOX_IMMUTABLEHTTP 400API keys: give at least one valid permission; a key never switches between sandbox and live — create a new one.
NOT_FOUNDLIST_NOT_FOUNDLEAD_NOT_FOUNDTEMPLATE_NOT_FOUNDAPP_NOT_FOUNDSHEET_NOT_FOUNDINVALID_MESSAGE_IDHTTP 400 / 404The resource does not exist for this account (or this key mode: a sandbox key does not see live objects), or the requested identifier is invalid (e.g. literal null or undefined). Check the id and the key.
METHOD_NOT_ALLOWEDHTTP 405That HTTP method does not exist on this route. See the endpoint reference.
UNSUPPORTED_MEDIA_TYPEPAYLOAD_TOO_LARGEFILE_TOO_LARGEINVALID_FILE_TYPEINVALID_FORM_DATAMISSING_FILEEMPTY_SHEETPARSE_FAILEDMAPPING_ERRORFETCH_FAILEDINVALID_PATHNO_FILE_PROVIDEDNO_DATA_PROVIDEDUPLOAD_FAILEDHTTP 400 / 413 / 415 / 502File or import refused: unsupported type, too large, empty, unrecognised columns or an unreachable URL. error.message says which; fix the file and resend.
Sending (SMS and common)
INSUFFICIENT_BALANCEHTTP 402Balance too low for this send. Top up in Dashboard → Billing; nothing was charged.
INTERNATIONAL_REQUIRES_TOPUPHTTP 403Sends outside Morocco are for accounts that have topped up at least once. Make a first top-up.
QUOTA_EXCEEDEDHTTP 429The daily quota set on your account is reached; it resets at midnight UTC. Raise it in the dashboard if needed. After this 429, retry with a new Idempotency-Key.
OPTED_OUTHTTP 403This recipient unsubscribed from your messages (STOP). Do not resend; remove them from your lists. They can opt back in themselves.
SENDER_ID_TOO_LONGSENDER_ID_TOO_SHORTSENDER_ID_INVALIDHTTP 400An alphanumeric Sender ID is 3 to 11 characters (letters, digits, space). Fix it, or omit it to use the default sender.
SENDER_ID_GENERICHTTP 400Generic Sender ID (INFO, ALERT, SERVICE…), not a brand. Use your brand name or omit it.
SENDER_ID_PROTECTED_BRANDHTTP 403The Sender ID names or resembles a protected institution (bank, carrier, public body). Reserved to the account approved for that exact name.
SENDER_ID_NOT_APPROVEDSENDER_ID_PENDINGSENDER_ID_IN_REVIEWSENDER_ID_REJECTEDSENDER_ID_APPROVEDHTTP 403 / 409Sender ID registration: not approved yet, under review, rejected, or already approved (409). Follow its state in Settings → Sender IDs.
SENDER_ID_NOT_REGISTEREDHTTP 403No longer returned since 2026-09-27: an unregistered Sender ID is sent automatically at the standard rate instead. Kept so code that checks for it stays valid.
SENDER_NOT_ASSIGNED_TO_WORKSPACEHTTP 403The specified Sender ID is not assigned to this workspace. Assign it in Settings → Workspaces.
NO_SENDER_ID_ASSIGNEDHTTP 422No Sender ID is assigned to this workspace. Assign an approved Sender ID in Settings → Workspaces.
WORKSPACE_BALANCE_EXHAUSTEDHTTP 402The balance allocated to this workspace is exhausted. Allocate more balance from the parent account in Settings → Workspaces.
SPAM_OR_PHISHING_DETECTEDCONTENT_BLOCKEDHTTP 400Content refused: a phishing link, a message speaking as a bank, carrier or public body, or an acceptable-use violation. The account is suspended pending review. To appeal: POST /v1/moderation/appeals with error.appeal.id, error.appeal.token and a note (no API key needed).
CONTENT_REFUSEDHTTP 400Content refused by the anti-abuse screen (first refusal). Nothing was sent and the account stays active; a second refusal suspends it. Reword, or appeal via POST /v1/moderation/appeals.
INVALID_APPEALAPPEAL_NOT_FOUNDAPPEAL_ALREADY_FILEDHTTP 400 / 404 / 409Appeal: id or token missing or wrong, or an appeal is already open for this refusal.
EMPTY_BATCHTOO_MANY_MESSAGESHTTP 400POST /v1/messages/bulk takes 1 to 10,000 messages per call. Split larger batches.
SEND_PERSIST_FAILEDHTTP 502The message could not be recorded; nothing was sent and nothing was charged. Retry with the same Idempotency-Key.
WHATSAPP_NOT_CONNECTEDHTTP 403No WhatsApp Business number connected to the account. Connect one in the dashboard; for a plain text, omit channel (SMS). Nothing was charged.
OUT_OF_24H_WINDOWHTTP 400Free-form WhatsApp message to a contact who has not written to you in 24 hours. Send an approved template, or an SMS. Nothing was charged.
CONTACT_HAS_NO_PHONEHTTP 400This contact uses a WhatsApp username and has not shared a phone number: they can only be reached on WhatsApp, not by SMS. Nothing was charged.
WHATSAPP_ONLY_FIELDBUTTONS_NOT_SUPPORTEDINTERACTIVE_NOT_SUPPORTEDHTTP 400A WhatsApp-only field (buttons, interactive, reaction, contacts, location, sticker, context) was sent on SMS. Add "channel": "whatsapp" or drop the field.
CONFLICTING_MESSAGE_TYPESCASCADE_NOT_SUPPORTEDINVALID_MESSAGEHTTP 400A message carries one content type; reaction, contacts, location and sticker combine with nothing and have no SMS equivalent (no cascade).
INVALID_REACTIONINVALID_CONTEXTINVALID_CONTACTSINVALID_LOCATIONINVALID_MEDIAHTTP 400Malformed WhatsApp object: reaction = {message_id, emoji}; context = {message_id}; contacts = 1–20 cards with name.formatted_name; location = numeric latitude/longitude; media = "id" OR an https "link", no caption on audio/sticker.
INVALID_INTERACTIVEINVALID_INTERACTIVE_TYPEMISSING_INTERACTIVE_ACTIONINTERACTIVE_BODY_TOO_LONGINVALID_CTA_URL_ACTIONMISSING_CTA_DISPLAY_TEXTCTA_DISPLAY_TEXT_TOO_LONGINVALID_LOCATION_REQUEST_ACTIONINVALID_FLOW_ACTIONMISSING_FLOW_IDMISSING_FLOW_CTAFLOW_CTA_TOO_LONGMISSING_CAROUSEL_CARDSTOO_MANY_CAROUSEL_CARDSINVALID_PRODUCT_ACTIONINVALID_PRODUCT_LISTMISSING_ORDER_ACTIONMISSING_ORDER_CURRENCYMISSING_ORDER_ITEMSMISSING_ORDER_PARAMETERSMISSING_REFERENCE_IDINVALID_TOTAL_AMOUNTMISSING_PHONE_NUMBERHTTP 400Malformed interactive message (list, buttons, cta_url, flow, carousel, product, order, location_request). error.message names the faulty field and the WhatsApp limit it broke.
MISSING_BUTTONS_ARRAYINVALID_BUTTONINVALID_BUTTON_COUNTINVALID_BUTTON_TEXTINVALID_BUTTON_TYPEINVALID_REPLY_BUTTONBUTTON_TEXT_TOO_LONGBUTTON_TITLE_TOO_LONGTOO_MANY_BUTTONSMISSING_COPY_CODEMISSING_LIST_BUTTONLIST_BUTTON_TOO_LONGMISSING_LIST_SECTIONSTOO_MANY_SECTIONSSECTION_TITLE_TOO_LONGINVALID_SECTION_ROWSTOO_MANY_ROWSMISSING_ROW_IDMISSING_ROW_TITLEROW_ID_TOO_LONGROW_TITLE_TOO_LONGROW_DESC_TOO_LONGHTTP 400Buttons or list outside WhatsApp's limits: at most 3 buttons, 20-character button titles, 10 sections and 10 rows, 24-character row titles, 72-character descriptions. error.message gives the exact limit.
META_SERVICE_UNAVAILABLEHTTP 502WhatsApp was briefly unavailable and the send failed after automatic retries. Retry in a few minutes.
Templates and campaigns
TEMPLATE_PENDING_REVIEWTEMPLATE_REJECTED_BY_REVIEWHTTP 409The template is waiting for EnvoiSMS review (it goes to Meta once approved), or that review rejected it: edit it to send it back to review.
TEMPLATE_EXISTSTEMPLATE_ALREADY_ON_METATEMPLATE_EDIT_NOT_ALLOWEDTEMPLATE_IMMUTABLE_FIELDHTTP 400 / 409A template with that name exists, is already at Meta (sync it), cannot be edited in its current state, or you are changing a field Meta freezes (name, language, the category of an approved template) — create a new one.
INVALID_LANGUAGEINVALID_META_TEMPLATEINVALID_AUTH_TEMPLATEMISSING_SAMPLE_VALUESMISSING_HEADER_EXAMPLEINVALID_HEADER_EXAMPLEHEADER_EXAMPLE_INVALID_SIZEHEADER_EXAMPLE_INVALID_TYPEHEADER_EXAMPLE_UNREACHABLEHTTP 400The template breaks Meta's rules: unknown language, missing variable samples, a header media that is missing, unreachable, the wrong type or too large, or a non-conforming authentication template. error.message says which.
INVALID_WABAWHATSAPP_TOKEN_EXPIREDMETA_API_ERRORMETA_SUBMIT_FAILEDMETA_UPLOAD_FAILEDMETA_NOT_CONFIGUREDHTTP 400 / 502 / 503Meta refused the operation on your WhatsApp Business account: a waba_id that is not yours, an expired connection token (reconnect the account in the dashboard) or a Meta error. Retry; if it persists, contact support.
TEMPLATE_NOT_APPROVEDMISSING_TEMPLATEMISSING_TEMPLATE_PARAMSHTTP 400WhatsApp campaign or conversation: the template is not approved, is missing, or variables are missing. MISSING_TEMPLATE_PARAMS lists what to supply in template_variables (_header_media_url, _button_<i>, or "static:value").
CONSENT_ATTESTATION_REQUIREDHTTP 400A WhatsApp marketing campaign requires "consent_attested": true: you confirm every contact on the list agreed to receive your WhatsApp messages. Send it only if that is true.
INVALID_WABA_SENDERMISSING_LISTEMPTY_LISTLIST_IN_USEHTTP 400Campaign: the WhatsApp sender is not connected to your account, the list is missing or has no eligible recipient, or the list is used by an active campaign (and cannot be deleted).
ALREADY_RUNNINGCAMPAIGN_RUNNINGCAMPAIGN_NOT_EDITABLENOT_PAUSEDNOT_RUNNINGHTTP 400 / 409The campaign's state does not allow this action: only a draft or scheduled campaign can be edited, and a running campaign must be paused before it is deleted.
Verification (OTP)
INVALID_CODEHTTP 400Wrong code. error.message states the attempts left; let the user try again.
MAX_ATTEMPTSHTTP 429Too many attempts: the session is closed. Start a new one with /v1/verify/send.
SESSION_NOT_FOUNDSESSION_EXPIREDINVALID_STATEINVALID_SESSION_DATAINVALID_SESSION_IDHTTP 400 / 404 / 409Session unknown, expired, invalid ID (e.g. literal null or undefined), already verified or voided, or created by a key of the other mode (sandbox/live). Start a new verification, using the same key for send, resend and check.
OTP_COOLDOWNOTP_RATE_LIMITHTTP 429A code was just sent to this number (30 s on SMS, 60 s on WhatsApp), or too many codes went there this hour. Wait Retry-After; offer "Resend" through /v1/verify/resend.
OTP_RESEND_LIMIT_EXCEEDEDHTTP 429Maximum resend attempts (4 dispatches) reached for this verification session. Wait for the session to expire before starting a new one.
OTP_COUNTRY_PAUSEDHTTP 429Codes to that country (outside Morocco) are paused for your account until 00:00 UTC: too few of today's codes there were confirmed, the pattern of automated abuse. Retry-After carries the seconds left.
COUNTRY_NOT_ALLOWEDHTTP 403Codes to that country are not open for your account or Verify app. Contact support to open it.
WHATSAPP_UNAVAILABLEDELIVERY_FAILEDINVALID_CODE_TYPEBAD_GATEWAYHTTP 400 / 502 / 503The code could not go out on WhatsApp (unavailable for the account, refused, or a non-numeric code). Nothing was charged; resend with "channel": "sms".
HLR_UNAVAILABLEHTTP 503The HLR query (/v1/hlr) could not be made: the service is momentarily unavailable or rate-limited. Retry in a few seconds; nothing was charged. A 502 BAD_GATEWAY on this route means the network did not answer — same rule, nothing is charged.
LOOKUP_FAILEDLOOKUP_UNAVAILABLEHTTP 500 / 503The carrier lookup (/v1/verify/lookup) failed or is unavailable. Retry later; nothing was charged.
Rate limits and idempotency
RATE_LIMITEDTOO_MANY_REQUESTSRATE_LIMIT_EXCEEDEDHTTP 429The key's per-minute request limit was exceeded. Wait Retry-After (seconds); retrying sooner only uses up the next window.
INVALID_IDEMPOTENCY_KEYHTTP 400Idempotency-Key is longer than 255 characters. Use a UUID or a short business identifier.
IDEMPOTENCY_IN_FLIGHTHTTP 409The original request with this key is still running. Wait Retry-After and retry with the same key.
IDEMPOTENCY_KEY_REUSEDHTTP 422This Idempotency-Key was already used with a different request body. A key names one request: generate a new one.
REPLAY_UNAVAILABLEHTTP 422This webhook delivery has no stored payload to resend. It cannot be replayed.
Account and billing
LIMIT_EXCEEDEDHTTP 403Maximum number of API keys or webhooks reached for your plan. Revoke one or change plan.
INVALID_AMOUNTINVALID_PACKINVALID_PAYMENT_METHODUNSUPPORTED_METHODALREADY_COMPLETEDTRANSACTION_CANCELLEDRECEIPT_NOT_AVAILABLEINVOICE_NOT_AVAILABLESTRIPE_INVOICES_UNAVAILABLENO_STRIPE_CUSTOMERINVALID_THRESHOLDINVALID_CYCLEINVALID_CYCLE_LIMITNO_PAID_TOPUPNO_FIELDSHTTP 400 / 403 / 404 / 409 / 410Top-up or auto-recharge: invalid amount, threshold, cycle cap, cycle, pack or payment method, first manual top-up required, transaction cancelled or already completed, or no invoice available (yet).
STRIPE_ERRORPORTAL_ERRORCUSTOMER_CREATION_FAILEDSTRIPE_NOT_CONFIGUREDPAYMENT_METHOD_NOT_CONFIGUREDNOT_AVAILABLEINVOICE_TEMPORARILY_UNAVAILABLEINVOICE_BEING_GENERATEDINVALID_SIGNATUREHTTP 401 / 500 / 502 / 503The payment or invoice page could not be obtained on our side. Nothing was charged; retry in a moment.
Server side
INTERNAL_ERRORDB_ERRORSERVICE_UNAVAILABLEBAD_REQUESTHTTP 400 / 500 / 503An error on our side. Retry with backoff (and the same Idempotency-Key for a send); contact support if it persists.
Delivery failure codes
An accepted message (202) can still fail later. Its error_code — on GET /v1/messages/{id} and in the message.failed webhook — is one of these. A WhatsApp message refused before it went out is refunded automatically.
META_WINDOW_EXPIREDMore than 24 hours since the contact's last message. Send an approved template or an SMS.
META_UNDELIVERABLEThe number cannot be reached on WhatsApp. Not retried automatically: use SMS.
META_MARKETING_LIMITWhatsApp's per-recipient marketing limit. Do not resend right away.
META_RATE_LIMITED / META_PAIR_RATE_LIMITEDThe number's throughput was exceeded, or too many messages went to this contact. Failed after automatic retries; slow down.
META_POLICY_BLOCKED / META_ACCOUNT_LOCKED / META_PAYMENT_ISSUE / META_PHONE_NOT_REGISTEREDThe sending WhatsApp number or account is restricted, locked, without a valid Meta payment method, or not registered. Check WhatsApp Manager; your EnvoiSMS funds are not the issue.
META_TEMPLATE_NOT_FOUND / META_TEMPLATE_PARAM_MISMATCH / META_TEMPLATE_PARAM_FORMATTemplate missing in that language or not approved yet, the wrong number of variables, or a variable in the wrong format (header media, button).
META_UNSUPPORTED_TYPEThis message type is not supported by WhatsApp for this recipient.
META_GENERIC_ERROR / META_SERVICE_UNAVAILABLEA temporary WhatsApp error that persisted after automatic retries. Resend in a few minutes.
CASCADE_TIMEOUTCascade: no delivery report within cascade_timeout; the message moved to the next channel (message.fallback event).
Limits
Limits#
Per key per minute. A new key's default by plan: Starter 60, Business 180, Pro 500, Enterprise 1,200. Beyond it: 429 RATE_LIMITED with Retry-After.
Up to 10,000 messages per POST /v1/messages/bulk call.
4- to 8-digit code (6 by default), valid 60 s to 30 min (10 min by default). Resend allowed after 30 s on SMS, 60 s on WhatsApp.
Idempotency-Key of up to 255 characters, kept 24 hours per account.
npm install envoismspip install envoismscomposer require envoisms/envoisms-phpcomposer require envoisms/laravel-otpgo get github.com/n4ouri/envoisms-gonpx -y @envoisms/mcp-serverSend a message
Queues one SMS or WhatsApp message to one recipient and debits its price. Returns 202 with the message id; the delivery outcome arrives later by webhook or through GET /v1/messages/:id.
| Field | Type | Requirement | Description |
|---|---|---|---|
| to | string | Required | Recipient number in E.164 format (+212612345678). |
| message | string | Required | Message text, at most 1,600 characters (longer text is cut). Alias: body. Optional on WhatsApp when the request carries a template, media, a reaction, contacts, a location or an interactive message. |
| sender_id | string | Optional | SMS: the name shown to the recipient (letters, digits and spaces, 3 to 11 characters; a generic header such as INFO or a bank’s name is refused). Omitted, the SMS goes out under our shared sender EnvoiSMS at 0.792 MAD. Your own name works with no registration at 1.70 MAD until it is validated with your RC, then at the standard rate once validated. WhatsApp: the connected number that sends. Aliases: from, sender. |
| channel | string | Optional | "sms" (default) or "whatsapp". "whatsapp" sends from your own connected WhatsApp Business number (otherwise 403 WHATSAPP_NOT_CONNECTED). Unless it is a template, a WhatsApp message is accepted only if the contact wrote to you in the last 24 hours; otherwise 400 OUT_OF_24H_WINDOW, with nothing charged. |
| cascade | boolean | string[] | Optional | true: tries WhatsApp, then falls back to SMS — at once if WhatsApp refuses the message or reports it failed, or when no delivery report arrives within cascade_timeout. Also accepts an ordered list such as ["whatsapp", "sms"]. Only a send that actually went out is billed; a WhatsApp message still waiting for its report did go out, so it is billed along with its SMS fallback and refunded if WhatsApp later reports it failed. |
| cascade_timeout | integer | Optional | With cascade: seconds to wait for a delivery report before the next channel, from 30 to 43200. Default: 120. Give your WhatsApp template a time-to-live (TTL) no longer than this, so a phone that comes back online does not receive both messages. |
| template | object | Optional | (WhatsApp) An approved template of your account: {"name", "language", "components"?}. Body variable values go in metadata.variables. The only message type allowed outside the 24-hour window; priced by the template’s category. |
| metadata | object | Optional | Key-value pairs stored with the message and returned in webhooks (not by GET /v1/messages). Keys the platform reads: variables (template values), media_url and media_type (the template’s media header), purpose: "otp" (see notes). |
| buttons | array | Optional | (WhatsApp) Up to 3 buttons {"type", "text"}; text at most 25 characters. type "copy" needs copy_code, "url" needs url (http or https), "call" needs phone_number. |
| interactive | object | Optional | (WhatsApp) Interactive message in WhatsApp’s format: list ("list"), reply buttons ("button", 1 to 3), form ("flow"), link button ("cta_url") or location request ("location_request_message"). |
| image | video | audio | document | sticker | object | Optional | (WhatsApp) Media by "id" (an uploaded media id) or by "link" (an https URL), never both. "caption" on image, video and document; "filename" on document. |
| reaction | object | Optional | (WhatsApp) {"message_id": wamid, "emoji": "👍"} — reacts to a message of the conversation; an empty emoji removes the reaction. Not billed. |
| contacts | array | Optional | (WhatsApp) Contact cards (at most 20): name.formatted_name required; phones, emails, urls, addresses, org, birthday optional. |
| location | object | Optional | (WhatsApp) Location pin: {"latitude", "longitude", "name"?, "address"?}. |
| context | object | Optional | (WhatsApp) {"message_id": wamid} — replies quoting an earlier message. Valid on every type except reaction. |
| phone_number_id | string | Optional | (WhatsApp) Meta id of the connected number that sends; defaults to your default number. |
- Required API key permission: send.
- Idempotency: send an Idempotency-Key header (at most 255 characters) to retry a request without sending twice. For 24 hours the same key with the same body returns the original response (header Idempotency-Replayed: true); with a different body, 422 IDEMPOTENCY_KEY_REUSED; while the first request is still running, 409 IDEMPOTENCY_IN_FLIGHT.
- Sandbox key (env_test_…): nothing is sent or debited; cost is 0, and the response adds "sandbox": true, "simulated_cost" (the price a live key would have paid) and, where relevant, "warnings" (what a live key would have been refused, such as a closed 24-hour window).
- One-time codes: /v1/verify/send handles the whole cycle and remains the recommended path. If you generate your own codes and send them here, add metadata: {"purpose": "otp"}: on a network-confirmed delivery failure to a Moroccan number while the code is under 10 minutes old, the platform resends it once over another SMS route, under the same message id and at no extra cost.
- Common errors: 400 INVALID_PHONE, 400 OUT_OF_24H_WINDOW, 400 SENDER_ID_GENERIC, 402 INSUFFICIENT_BALANCE, 403 WHATSAPP_NOT_CONNECTED, 403 SENDER_ID_PROTECTED_BRAND. A refused request is never debited.
- Formatting: line breaks (\n) work on every channel. SMS is plain text; WhatsApp renders *bold*, _italics_ and ~strikethrough~.
curl -X POST "https://api.envoisms.ma/v1/messages" \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+212612345678",
"message": "Your order #1042 has shipped."
}'{
"id": "msg_3f9a1c7e5b2d4a6f8e0c1b3d",
"to": "+212612345678",
"channel": "sms",
"cascade": false,
"status": "queued",
"cost": { "eur": 0.072, "mad": 0.792 },
"segments": 1,
"created_at": "2026-10-22T09:14:03.512Z"
}Bulk send
Queues up to 10,000 messages in one call, each with its own recipient and text. Returns 202 with a batch_id, the accepted messages and, where any, the refused ones.
| Field | Type | Requirement | Description |
|---|---|---|---|
| messages | array | Required | Array of 1 to 10,000 objects: "to" (E.164), "message" (or "body"), and optionally "metadata" and "buttons" (WhatsApp). |
| sender_id | string | Optional | Sender for the whole batch (same rules as POST /v1/messages). Aliases: from, sender. |
| channel | string | Optional | "sms" (default) or "whatsapp". On WhatsApp a bulk send carries no template: every recipient must be inside their 24-hour window. |
- Required API key permission: send.
- The batch is validated as a whole before anything is sent: a single invalid entry fails the request with 400 INVALID_MESSAGE, listing the first 20 faulty entries (index and rule). The balance is checked for the whole batch (402 INSUFFICIENT_BALANCE).
- "rejected" lists entries refused one by one (opted out, closed 24-hour window, …) with their code; they are not debited. It appears only when there are any. "estimated_cost" is the batch estimate before refusals.
- Idempotency-Key is accepted, with the same rules as POST /v1/messages.
curl -X POST "https://api.envoisms.ma/v1/messages/bulk" \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"messages": [
{
"to": "+212611111111",
"message": "Your appointment is tomorrow at 10:00."
},
{
"to": "+212622222222",
"message": "Your appointment is tomorrow at 11:30."
}
]
}'{
"batch_id": "batch_9a3c5e7f1b2d4c6e8a0f2b4d",
"total": 2,
"channel": "sms",
"estimated_cost": { "eur": 0.144, "mad": 1.584 },
"messages": [
{ "id": "msg_1a2b3c4d5e6f7a8b9c0d1e2f", "to": "+212611111111", "status": "queued" },
{ "id": "msg_3c4d5e6f7a8b9c0d1e2f3a4b", "to": "+212622222222", "status": "queued" }
],
"rejected": [
{ "to": "+212633333333", "code": "OPTED_OUT", "message": "This number opted out of messages from your account" }
]
}WhatsApp typing indicator
Marks a received WhatsApp message as read and shows its sender "typing…" while you prepare the reply (until you reply, or about 25 seconds). Nothing is stored or billed.
| Field | Type | Requirement | Description |
|---|---|---|---|
| message_id | string | Required | WhatsApp id (wamid) of the received message you are answering. |
| typing_indicator | boolean | Optional | false sends the read receipt only. Default: true. |
| phone_number_id | string | Optional | Connected number that received the message; defaults to your default number. |
- Required API key permission: send.
- With a sandbox key nothing reaches WhatsApp and the response is {"status": "simulated", "sandbox": true, "message_id", "typing_indicator"}.
curl -X POST "https://api.envoisms.ma/v1/messages/typing" \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"message_id": "wamid.HBgMMjEyNjEyMzQ1Njc4FQIAEhg",
"typing_indicator": true
}'{
"status": "ok",
"message_id": "wamid.HBgLMjEyNjEyMzQ1Njc4FQIAEhgg",
"typing_indicator": true
}List messages
Returns the account’s messages, newest first, with filters and pagination. format=csv exports the whole filtered set.
| Parameter | Type | Requirement | Description |
|---|---|---|---|
| limit | integer | Optional | Number of results: 50 by default, at most 200. |
| offset | integer | Optional | Number of results to skip (pagination). Default: 0. |
| status | string | Optional | queued, sent, delivered, read (WhatsApp), failed, undeliverable or unconfirmed. |
| channel | string | Optional | sms or whatsapp. |
| campaign_id | string | Optional | Messages of one campaign (cmp_…). |
| from_date | string | Optional | Created on or after this date (UTC). Write it YYYY-MM-DD: it is compared with created_at, formatted "YYYY-MM-DD HH:MM:SS". |
| to_date | string | Optional | Created up to this date (UTC), same comparison. To include a whole day, pass the following day. |
| format | string | Optional | csv: downloads the whole filtered set (up to the export cap, stated in the file) instead of one page. |
- Required API key permission: status.
- A key sees only messages of its own mode: a sandbox key lists simulated sends only, a live key real sends only.
curl -X GET "https://api.envoisms.ma/v1/messages?limit=50" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"data": [
{
"id": "msg_3f9a1c7e5b2d4a6f8e0c1b3d",
"campaign_id": null,
"to": "+212612345678",
"channel": "sms",
"body": "Votre commande 4512 est expédiée.",
"sender_id": "MABOUTIQUE",
"unicode": 0,
"segments": 1,
"status": "delivered",
"error_code": null,
"error_message": null,
"cost_eur": 0.072,
"cost_mad": 0.792,
"scheduled_at": null,
"sent_at": "2026-10-22 09:14:05",
"delivered_at": "2026-10-22 09:14:09",
"read_at": null,
"failed_at": null,
"created_at": "2026-10-22 09:14:03",
"operator": "Maroc Telecom",
"sandbox": 0
}
],
"limit": 50,
"offset": 0,
"total": 1
}Retrieve a message
Returns one message and its delivery state. 404 NOT_FOUND when the id does not belong to the account or to the key’s mode.
- Required API key permission: status.
- Statuses: queued, sent, delivered, read (WhatsApp), failed, undeliverable, unconfirmed. "unconfirmed" means the operator neither confirmed nor denied delivery; a receipt arriving later can still replace it.
- The metadata given at send time is not returned here; it travels with the webhooks.
curl -X GET "https://api.envoisms.ma/v1/messages/{id}" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"id": "msg_3f9a1c7e5b2d4a6f8e0c1b3d",
"campaign_id": null,
"to": "+212612345678",
"channel": "sms",
"body": "Votre commande 4512 est expédiée.",
"sender_id": "MABOUTIQUE",
"unicode": 0,
"segments": 1,
"status": "delivered",
"error_code": null,
"error_message": null,
"cost_eur": 0.072,
"cost_mad": 0.792,
"scheduled_at": null,
"sent_at": "2026-10-22 09:14:05",
"delivered_at": "2026-10-22 09:14:09",
"read_at": null,
"failed_at": null,
"created_at": "2026-10-22 09:14:03",
"operator": "Maroc Telecom",
"sandbox": 0
}List templates
Returns the account’s active templates (SMS and WhatsApp), newest first, whatever their status.
- Required API key permission: send.
- "variables" is a JSON string describing the template’s structure: variables, sample values, header, footer, buttons, carousel and review.
- WhatsApp statuses: pending_admin (EnvoiSMS review), pending_meta, approved, rejected, paused, disabled, in_appeal, flagged, deleted_on_meta. An SMS template is approved as soon as it is created.
curl -X GET "https://api.envoisms.ma/v1/templates" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"data": [
{
"id": "tpl_4c1e9a7b3d5f2e8a6c0b1d3f",
"account_id": "acct_7b2e4c6a8d0f1e3b5c7a9d2f",
"name": "order_shipped",
"channel": "whatsapp",
"body": "Bonjour {{1}}, votre commande {{2}} est expédiée.",
"variables": "{\"variables\":[\"1\",\"2\"],\"parameter_format\":\"positional\",\"sample_values\":{\"1\":\"Amine\",\"2\":\"4512\"},\"header_type\":\"none\",\"header_text\":null,\"footer\":null,\"buttons\":[]}",
"category": "utility",
"active": 1,
"created_at": "2026-10-20 14:02:11",
"status": "approved",
"rejection_reason": null,
"meta_template_id": "1203948576102938",
"language": "fr",
"quality_score": "GREEN",
"quality_updated_at": "2026-10-21 08:00:00",
"waba_id": null
}
]
}Create a template
Creates an SMS template (usable at once) or a WhatsApp template (reviewed by EnvoiSMS, then submitted to Meta on your own WhatsApp Business Account). Returns 201 with the created row.
| Field | Type | Requirement | Description |
|---|---|---|---|
| name | string | Required | Template name. Lowercased; any character other than a-z, 0-9 and _ becomes _. |
| channel | string | Required | sms, whatsapp, telegram or any. |
| body | string | Required | Template text. WhatsApp: at most 1,024 characters; variables either all numbered from {{1}} and consecutive, or all named in lowercase ({{first_name}}); never at the very start or end, never adjacent. Not required for a WhatsApp "authentication" template, whose text Meta fixes. |
| category | string | Optional | marketing (default), utility or authentication. otp maps to authentication, transactional to utility. Meta may reassign the category at review. |
| language | string | Optional | (WhatsApp) Meta language code, such as fr, ar, en_US, es. Default: fr. |
| sample_values | object | string[] | Optional | (WhatsApp) One example value per variable, required by Meta: {"1": "Amine"} or ["Amine"]. Without it, 400 MISSING_SAMPLE_VALUES. |
| variables | string[] | Optional | (SMS) Names of the text’s variables, stored as given. |
| header_type | string | Optional | (WhatsApp) none (default), text, image, video or document. A text header takes header_text (60 characters, at most one variable, with header_sample); a media header requires header_example_url. |
| header_text | header_sample | header_example_url | string | Optional | (WhatsApp) Header text; example value of its variable; public https link to a sample file for an image, video or document header (400 MISSING_HEADER_EXAMPLE when missing). |
| footer | string | Optional | (WhatsApp) Footer, at most 60 characters. |
| buttons | array | Optional | (WhatsApp) Up to 10 buttons {"type", "text"} (25 characters). Types: quick_reply; url (with an http(s) url, and url_example when the link ends in a variable); phone_number (with phone_number); copy_code (with copy_code); flow (with flow_id, the id of one of your published WhatsApp Flows, and navigate_screen, "LEAD" by default). |
| carousel | object | Optional | (WhatsApp, marketing or utility) {"header_type": "image" | "video", "cards": [...]}: 2 to 10 cards, each {"header_example_url" (https), "body" (160 characters, no variable), "buttons" (1 or 2, quick_reply or url without a variable)}. Every card carries the same buttons. A carousel template has no header, footer or buttons of its own. |
| add_security_recommendation | code_expiration_minutes | otp_type | supported_apps | mixed | Optional | (WhatsApp, authentication) Meta’s security notice (boolean); code validity, 1 to 90 minutes; COPY_CODE (default), ONE_TAP or ZERO_TAP; supported_apps [{"package_name", "signature_hash" (11 characters)}], required for ONE_TAP and ZERO_TAP. ZERO_TAP also requires zero_tap_terms_accepted: true. |
| waba_id | string | Optional | (WhatsApp) Target WhatsApp Business Account when the account has several; must be one of your connected accounts (otherwise 400 INVALID_WABA). |
- Required API key permission: send.
- Review: a WhatsApp template is created as pending_admin. Automatic checks (impersonation of a bank, phishing link, throwaway link host, inactive account) run at once; when they find nothing, the template is submitted to Meta (pending_meta) and the response already shows it. Otherwise it stays pending_admin for a person to review. Only Meta approves it (approved) and sets its final category.
- Errors: 400 MISSING_FIELD, INVALID_CHANNEL, INVALID_LANGUAGE, INVALID_META_TEMPLATE, MISSING_SAMPLE_VALUES, MISSING_HEADER_EXAMPLE, INVALID_AUTH_TEMPLATE, INVALID_WABA; 409 TEMPLATE_EXISTS (same name and language already active; a rejected template of the same name is replaced).
curl -X POST "https://api.envoisms.ma/v1/templates" \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "order_shipped",
"channel": "whatsapp",
"category": "utility",
"language": "fr",
"body": "Bonjour {{1}}, votre commande {{2}} est expédiée.",
"sample_values": [
"Karim",
"#1042"
]
}'{
"id": "tpl_8e2a4c6b1d3f5a7c9e0b2d4f",
"account_id": "acct_7b2e4c6a8d0f1e3b5c7a9d2f",
"name": "catalogue_automne",
"channel": "whatsapp",
"body": "Découvrez notre sélection d’automne, {{1}} !",
"variables": "{\"header_type\":\"none\",\"header_text\":null,\"footer\":null,\"buttons\":[],\"carousel\":{\"header_type\":\"image\",\"cards\":[{\"header_example_url\":\"https://example.ma/veste.jpg\",\"body\":\"Veste en laine\",\"buttons\":[{\"type\":\"url\",\"text\":\"Voir\",\"url\":\"https://example.ma/veste\"}]},{\"header_example_url\":\"https://example.ma/pull.jpg\",\"body\":\"Pull en coton\",\"buttons\":[{\"type\":\"url\",\"text\":\"Voir\",\"url\":\"https://example.ma/pull\"}]}]},\"variables\":[\"1\"],\"parameter_format\":\"positional\",\"sample_values\":{\"1\":\"Amine\"}}",
"category": "marketing",
"active": 1,
"created_at": "2026-10-22 09:20:41",
"status": "pending_meta",
"rejection_reason": null,
"meta_template_id": "1203948576102944",
"language": "fr",
"quality_score": null,
"quality_updated_at": null,
"waba_id": null
}Retrieve a template
Returns one active template of the account, with its status and, when rejected, rejection_reason. 404 NOT_FOUND otherwise.
- Required API key permission: send.
curl -X GET "https://api.envoisms.ma/v1/templates/{id}" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"id": "tpl_4c1e9a7b3d5f2e8a6c0b1d3f",
"account_id": "acct_7b2e4c6a8d0f1e3b5c7a9d2f",
"name": "order_shipped",
"channel": "whatsapp",
"body": "Bonjour {{1}}, votre commande {{2}} est expédiée.",
"variables": "{\"variables\":[\"1\",\"2\"],\"parameter_format\":\"positional\",\"sample_values\":{\"1\":\"Amine\",\"2\":\"4512\"},\"header_type\":\"none\",\"header_text\":null,\"footer\":null,\"buttons\":[]}",
"category": "utility",
"active": 1,
"created_at": "2026-10-20 14:02:11",
"status": "approved",
"rejection_reason": null,
"meta_template_id": "1203948576102938",
"language": "fr",
"quality_score": "GREEN",
"quality_updated_at": "2026-10-21 08:00:00",
"waba_id": null
}Update a template
Updates a template with the same fields as creation; only the fields you send change. Returns the updated row.
| Field | Type | Requirement | Description |
|---|---|---|---|
| name | body | category | language | sample_values | header_* | footer | buttons | carousel | mixed | Optional | Same fields and rules as POST /v1/templates. An SMS template accepts only name, body, category and variables. |
- Required API key permission: send.
- WhatsApp, template not yet on Meta: the edit sends it back to review (pending_admin, then the automatic checks). Template already on Meta: the edit is submitted to Meta and moves it back to pending_meta; name and language can no longer change (400 TEMPLATE_IMMUTABLE_FIELD), nor can an approved template’s category; no edit while pending_meta, in_appeal or disabled (409 TEMPLATE_EDIT_NOT_ALLOWED).
curl -X PATCH "https://api.envoisms.ma/v1/templates/{id}" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"id": "tpl_4c1e9a7b3d5f2e8a6c0b1d3f",
"account_id": "acct_7b2e4c6a8d0f1e3b5c7a9d2f",
"name": "order_shipped",
"channel": "whatsapp",
"body": "Bonjour {{1}}, votre commande {{2}} est en route.",
"variables": "{\"variables\":[\"1\",\"2\"],\"parameter_format\":\"positional\",\"sample_values\":{\"1\":\"Amine\",\"2\":\"4512\"},\"header_type\":\"none\",\"header_text\":null,\"footer\":null,\"buttons\":[]}",
"category": "utility",
"active": 1,
"created_at": "2026-10-20 14:02:11",
"status": "pending_meta",
"rejection_reason": null,
"meta_template_id": "1203948576102938",
"language": "fr",
"quality_score": "GREEN",
"quality_updated_at": "2026-10-21 08:00:00",
"waba_id": null
}Delete a template
Deactivates the template. A WhatsApp template already on Meta is deleted there too; meta_deleted says whether Meta did, meta_note why not.
- Required API key permission: send.
curl -X DELETE "https://api.envoisms.ma/v1/templates/{id}" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"deleted": true,
"id": "tpl_4c1e9a7b3d5f2e8a6c0b1d3f",
"meta_deleted": true
}Submit a template to Meta
Submits to Meta a WhatsApp template that already passed EnvoiSMS review but is not on Meta (Meta deleted it, for example). It is not a way around review.
- Required API key permission: send.
- Refusals: 409 TEMPLATE_PENDING_REVIEW (the template awaits EnvoiSMS review; it goes to Meta once approved), 409 TEMPLATE_REJECTED_BY_REVIEW (rejected by review; edit it to send it back to review), 409 TEMPLATE_ALREADY_ON_META, 400 INVALID_CHANNEL (not a WhatsApp template).
curl -X POST "https://api.envoisms.ma/v1/templates/{id}/submit-meta" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"ok": true,
"template": {
"id": "tpl_4c1e9a7b3d5f2e8a6c0b1d3f",
"account_id": "acct_7b2e4c6a8d0f1e3b5c7a9d2f",
"name": "order_shipped",
"channel": "whatsapp",
"body": "Bonjour {{1}}, votre commande {{2}} est expédiée.",
"variables": "{\"variables\":[\"1\",\"2\"],\"parameter_format\":\"positional\",\"sample_values\":{\"1\":\"Amine\",\"2\":\"4512\"},\"header_type\":\"none\",\"header_text\":null,\"footer\":null,\"buttons\":[]}",
"category": "utility",
"active": 1,
"created_at": "2026-10-20 14:02:11",
"status": "pending_meta",
"rejection_reason": null,
"meta_template_id": "1203948576102951",
"language": "fr",
"quality_score": null,
"quality_updated_at": null,
"waba_id": null
},
"meta": { "id": "1203948576102951", "status": "PENDING", "category": "UTILITY" }
}Sync templates from Meta
Imports the templates of your WhatsApp Business Accounts (status, category, quality). A template Meta no longer holds becomes deleted_on_meta, unless the sync is incomplete (complete: false).
- Required API key permission: send.
curl -X POST "https://api.envoisms.ma/v1/templates/sync" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"ok": true,
"wabas": ["104857392017465"],
"fetched": 12,
"updated": 9,
"inserted": 3,
"deduped": 0,
"marked_deleted": 1,
"complete": true,
"errors": []
}List Sender IDs
Returns your SMS Sender ID requests and their status, plus a name suggested from your profile.
- Required API key permission: sender_id.
curl -X GET "https://api.envoisms.ma/v1/sender-ids" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"data": [
{
"id": "sid_6a2c8e4b0d1f3a5c7e9b2d4f",
"sender_id": "MABOUTIQUE",
"status": "approved",
"rc_url": "/v1/sender-ids/rc/acct_7b2e4c6a8d0f1e3b5c7a9d2f/rc_1f3a5c7e9b2d4f6a8c0e2b4d.pdf",
"note": null,
"requested_at": "2026-10-02 10:15:00",
"reviewed_at": "2026-10-09 16:40:00",
"company_website": "https://maboutique.ma",
"sample_content": "Votre commande 4512 est expédiée.",
"message_type": "transactional",
"estimated_volume": "5000/mois",
"operator_emailed_at": "2026-10-02 10:20:00"
}
],
"suggestedSenderId": "MABOUTIQUE"
}Request a Sender ID
Files an SMS Sender ID request with your commercial register as a PDF. multipart/form-data body. Returns 201 (200 when an existing request is updated) with status pending.
| Field | Type | Requirement | Description |
|---|---|---|---|
| sender_id | string | Required | Requested name: unaccented letters, digits and spaces, at most 11 characters, at least one letter. A generic header (INFO, ALERT, …) or a protected institution’s name is refused. |
| file | file | Required | Commercial register, a PDF of at most 5 MB. |
| message_type | string | Optional | transactional (default) or promotional. |
| company_website | sample_content | estimated_volume | string | Optional | Company website, sample message and estimated volume, passed to the operators with the request. |
- Required API key permission: sender_id.
- Errors: 400 INVALID_FORM_DATA, MISSING_FIELD, INVALID_FIELD, SENDER_ID_GENERIC, INVALID_FILE_TYPE, FILE_TOO_LARGE; 403 SENDER_ID_PROTECTED_BRAND; 409 SENDER_ID_PENDING or SENDER_ID_APPROVED.
curl -X POST "https://api.envoisms.ma/v1/sender-ids" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"id": "sid_6a2c8e4b0d1f3a5c7e9b2d4f",
"sender_id": "MABOUTIQUE",
"status": "pending",
"rc_url": "/v1/sender-ids/rc/acct_7b2e4c6a8d0f1e3b5c7a9d2f/rc_1f3a5c7e9b2d4f6a8c0e2b4d.pdf"
}WhatsApp Business connection
Returns the state of your connected WhatsApp Business numbers and the default number’s checklist (readiness): what, according to Meta, stops it from sending. Read-only; no secret is returned.
- Any valid API key: no specific permission is required.
- readiness: can_send is AVAILABLE, LIMITED, BLOCKED or unknown (Meta did not answer); blockers lists Meta’s reasons (entity, reason, resolution); payment_method is missing when Meta reports no payment method, unchecked otherwise; business_verification is Meta’s business verification status. readiness is null without a connected number. What Meta did not say comes back null, never as a healthy default.
- Connecting, disconnecting or reconfiguring a number, and every write under /v1/whatsapp (profile, settings, tools), happens from a dashboard session: an API key gets 403 FORBIDDEN. Only POST /v1/whatsapp/lookup-phone and POST /v1/whatsapp/media/upload stay open to keys.
curl -X GET "https://api.envoisms.ma/v1/whatsapp/waba-connection" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"readiness": {
"waba_id": "104857392017465",
"payment_method": "unchecked",
"can_send": "AVAILABLE",
"blockers": [],
"business_verification": "verified",
"approved_templates": 4,
"checked_at": "2026-10-22T08:00:12.000Z"
},
"connected": true,
"mode": "custom_waba",
"gateway_number": "+212 708-789095",
"waba_id": "104857392017465",
"phone_number_id": "109283746501928",
"phone_number": "+212522000000",
"name": "Ma Boutique",
"status": "connected",
"error": null,
"quality_rating": "GREEN",
"messaging_limit_tier": "TIER_1K",
"connected_at": "2026-09-30 11:02:44",
"meta_app_id": "1029384756102938",
"meta_config_id": "2039485761029384",
"preverified_phone_ids": [],
"connections": [
{
"id": "5f1c9e3a7b2d4f6a8c0e1b3d5f7a9c2e",
"waba_id": "104857392017465",
"phone_number_id": "109283746501928",
"phone_number": "+212522000000",
"verified_name": "Ma Boutique",
"status": "connected",
"is_default": true,
"webhook_url": null,
"settings": {},
"created_at": "2026-09-30 11:02:44",
"updated_at": "2026-10-20 08:15:02"
}
]
}WhatsApp Business profile
Returns the public profile of one of your numbers as Meta holds it: about, address, description, email, picture, websites, vertical.
| Parameter | Type | Requirement | Description |
|---|---|---|---|
| phone_number_id | string | Optional | Connected number to read; defaults to your default number. |
- Any valid API key: no specific permission is required.
- The profile is updated from the dashboard; with an API key, POST /v1/whatsapp/profile returns 403 FORBIDDEN.
curl -X GET "https://api.envoisms.ma/v1/whatsapp/profile" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"ok": true,
"phone_number_id": "109283746501928",
"profile": {
"about": "Service client",
"address": "Casablanca, Maroc",
"description": "Prêt-à-porter, livraison partout au Maroc.",
"email": "contact@maboutique.ma",
"profile_picture_url": "https://pps.whatsapp.net/v/t61/...",
"websites": ["https://maboutique.ma"],
"vertical": "APPAREL"
}
}Send a verification code
Generates a one-time code, sends it and opens a verification session. You store no code: validate what the user types with POST /v1/verify/check. Returns 202.
| Field | Type | Requirement | Description |
|---|---|---|---|
| to | string | Required | Recipient in E.164 format. |
| channel | string | Optional | "sms" (default) or "whatsapp". On WhatsApp the code goes out as an authentication template — from your own number when you have connected one with an approved authentication template, otherwise from the EnvoiSMS number — and falls back to SMS if Meta refuses the send. |
| app_id | string | Optional | Verify application (vra_…) created in the dashboard: it sets length, validity, attempts, channels and cascade, and overrides code_length and expiry. |
| code_length | integer | Optional | Code length, 4 to 8 digits. Default: 6. |
| expiry | integer | Optional | Code validity in seconds, 60 to 1800. Default: 600. On WhatsApp, capped at 600. |
| brand | string | Optional | (SMS) Name shown in the message, at most 32 characters. Default: EnvoiSMS. A generic name is replaced by ENVOISMS; a protected institution’s name is refused (403 SENDER_ID_PROTECTED_BRAND). |
| template | string | Optional | (SMS) Custom text with {{code}} and {{brand}}. |
| otp_button_text | string | Optional | (WhatsApp) Copy button label, at most 25 characters. |
| otp_type | package_name | signature_hash | string | Optional | (WhatsApp) copy_code (default), one_tap, zero_tap or url; for one_tap and zero_tap, the Android package name and signature hash of the app that receives the code. |
| web_otp_domain | app_hash | string | Optional | (SMS) Domain for WebOTP autofill (appends "@domain #code" to the SMS); 11-character Android app hash for the SMS Retriever API. |
| auto_cascade | boolean | Optional | Also sends the SMS fallback when the first channel’s timeout expires without delivery. Overrides the Verify application’s setting. |
- Required API key permission: verify.
- On WhatsApp the response carries "channel": "whatsapp", "resend_after_seconds": 60, "fallback_channels": ["sms", "whatsapp"] and "cost" ({eur, mad}), without message_id. With a sandbox key nothing is sent or debited and the response adds "sandbox": true, "simulated": true and "sandbox_code", the code to submit to /v1/verify/check.
- Per-number limits: one send every 30 s on SMS and 60 s on WhatsApp (429 OTP_COOLDOWN, with Retry-After), 5 per hour and 10 per day (429 OTP_RATE_LIMIT); 200 sends per day per account.
- Autofill: on your site, add autocomplete="one-time-code" and inputmode="numeric" to the code input field.
curl -X POST "https://api.envoisms.ma/v1/verify/send" \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+212612345678",
"brand": "MyApp"
}'{
"session_id": "vrf_7e2a9c4b1d3f5a7c9e0b2d4f",
"message_id": "msg_2b4d6f8a0c1e3b5d7f9a1c3e",
"to": "+212612345678",
"channel": "sms",
"expires_at": "2026-10-22T09:24:03.000Z",
"resend_after_seconds": 30,
"fallback_channels": [],
"status": "sent"
}Resend the code
Resends the code of a session that is still open, by SMS or WhatsApp, once the cooldown has passed. The code does not change. Returns 200.
| Field | Type | Requirement | Description |
|---|---|---|---|
| session_id | string | Required | Session returned by /v1/verify/send (vrf_…). |
| channel | string | Optional | "sms" (default) or "whatsapp". |
- Required API key permission: verify.
- The cooldown runs from the last actual send: 30 s after an SMS, 60 s after a WhatsApp message. GET /v1/verify/:id gives the countdown (resend_available_in_seconds) and the 429 OTP_COOLDOWN carries Retry-After. The window belongs to your account and the number.
- Errors: 404 SESSION_NOT_FOUND (unknown, expired, or from the other key mode), 400 SESSION_EXPIRED, 400 INVALID_STATE (session already verified or closed), 400 UNSUPPORTED_CHANNEL, 429 OTP_COOLDOWN. With a sandbox key the response adds "sandbox": true, "simulated": true and "sandbox_code".
curl -X POST "https://api.envoisms.ma/v1/verify/resend" \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"session_id": "vrf_…",
"channel": "sms"
}'{
"session_id": "vrf_7e2a9c4b1d3f5a7c9e0b2d4f",
"message_id": "msg_8c0e2b4d6f1a3c5e7b9d1f3a",
"to": "+212612345678",
"channel": "sms",
"status": "sent",
"resend_after_seconds": 30,
"expires_at": "2026-10-22T09:24:03.000Z"
}Check a code
Compares the code the user typed with the session’s code. A correct code closes the session: a second check returns 404.
| Field | Type | Requirement | Description |
|---|---|---|---|
| session_id | string | Required | Session returned by /v1/verify/send. |
| code | string | Required | The code the user typed. |
- Required API key permission: status.
- Errors: 400 INVALID_CODE (the message gives the attempts left), 429 MAX_ATTEMPTS (the session becomes failed), 404 SESSION_NOT_FOUND — including for an expired or already verified session. /v1/verify never returns EXPIRED_CODE.
curl -X POST "https://api.envoisms.ma/v1/verify/check" \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"session_id": "vrf_…",
"code": "483921"
}'{
"session_id": "vrf_7e2a9c4b1d3f5a7c9e0b2d4f",
"verified": true,
"verified_at": "2026-10-22T09:15:12.408Z"
}Void a session
Closes a pending session: the code sent is no longer accepted. Also available as POST /v1/verify/:id/void.
| Field | Type | Requirement | Description |
|---|---|---|---|
| session_id | string | Required | Session to void (omit when the id is in the path). |
- Required API key permission: status.
- Errors: 404 SESSION_NOT_FOUND, 409 INVALID_STATE (session already verified, failed or expired). When the Verify application has a callback_url, it is notified (expired).
curl -X POST "https://api.envoisms.ma/v1/verify/void" \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"session_id": "vrf_…"
}'{
"session_id": "vrf_7e2a9c4b1d3f5a7c9e0b2d4f",
"status": "expired",
"voided": true
}Retrieve a session
Returns a verification session’s state: status, attempts, countdown before resend and the delivery outcome of each send.
- Required API key permission: status.
- status is pending, verified, failed or expired. 404 NOT_FOUND when the session does not belong to the account.
curl -X GET "https://api.envoisms.ma/v1/verify/{id}" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"id": "vrf_7e2a9c4b1d3f5a7c9e0b2d4f",
"session_id": "vrf_7e2a9c4b1d3f5a7c9e0b2d4f",
"to": "+212612345678",
"channel": "sms",
"status": "pending",
"attempts_used": 1,
"attempts_remaining": 2,
"max_attempts": 3,
"resend_available_in_seconds": 0,
"can_resend_now": true,
"expires_at": "2026-10-22T09:24:03.000Z",
"verified_at": null,
"created_at": "2026-10-22 09:14:03",
"app_id": null,
"app_name": null,
"attempted_channels": [],
"deliveries": [
{
"channel": "sms",
"kind": "send",
"status": "delivered",
"simulated": false,
"submitted_at": "2026-10-22 09:14:03",
"delivered_at": "2026-10-22 09:14:07",
"read_at": null,
"failed_at": null,
"error_code": null,
"error_message": null
}
],
"callback_delivered": null,
"cascade_triggered": false
}Look up a number’s operator
Validates a number and returns its country and, for a Moroccan mobile, the operator that serves it, including ported numbers. Billed per successful lookup; an invalid number (400) is not billed.
| Parameter | Type | Requirement | Description |
|---|---|---|---|
| number | string | Optional | Alternative to the path parameter: GET /v1/lookup?number=%2B212612345678. |
- Required API key permission: send.
- Errors: 400 MISSING_FIELD, 400 INVALID_PHONE, 402 INSUFFICIENT_BALANCE. With a sandbox key the answer is real but nothing is debited, and it carries "sandbox": true. type is mobile or unknown; operator is null when unknown.
curl -X GET "https://api.envoisms.ma/v1/lookup/{number}" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"id": "lkp_5d7f9b1c3e5a7c9e1b3d5f7a",
"number": "+212612345678",
"valid": true,
"country_iso": "MA",
"type": "mobile",
"operator": "Maroc Telecom",
"ported": false,
"cost": { "eur": 0.005, "mad": 0.055 }
}Query the network (HLR)
Queries the operator’s Home Location Register: is the number reachable right now, which network serves it today (ported numbers included), is it roaming, mobile or fixed line. Billed per answer from the network, whatever it is; an invalid number (400) or a query that did not complete (502/503) is not billed.
| Parameter | Type | Requirement | Description |
|---|---|---|---|
| number | string | Optional | Alternative to the path parameter: GET /v1/hlr?number=%2B212612345678. |
- Required API key permission: send.
- status is active (reachable), absent (known but switched off or out of coverage), inactive (not in service), invalid or unknown. The answer takes one network round trip (1–5 s). source says where the answer came from: network = the operator’s register queried live (status reflects reachability); portability = the operators’ number-portability database (Moroccan numbers): operator, ported flag and line in service (active), without saying whether the handset is on right now. Errors: 400 MISSING_FIELD, 400 INVALID_PHONE, 402 INSUFFICIENT_BALANCE, 502 BAD_GATEWAY, 503 HLR_UNAVAILABLE. With a sandbox key the answer is simulated from what the platform already knows, nothing is queried or debited, and it carries "sandbox": true.
curl -X GET "https://api.envoisms.ma/v1/hlr/{number}" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"id": "hlr_5d7f9b1c3e5a7c9e1b3d5f7a",
"number": "+212612345678",
"valid": true,
"status": "active",
"type": "mobile",
"country_iso": "MA",
"operator": "Maroc Telecom",
"mcc": "604",
"mnc": "01",
"ported": false,
"ported_operator": null,
"ported_mnc": null,
"roaming": false,
"roaming_country_iso": null,
"roaming_operator": null,
"roaming_mnc": null,
"source": "portability",
"cost": { "eur": 0.018, "mad": 0.20 }
}Validate a number’s format
Validates a number’s format and returns its local and international forms, country, location, allocating carrier and line type. Not billed.
| Field | Type | Requirement | Description |
|---|---|---|---|
| number | string | Required | Number, in local or international format. |
| country_code | string | Optional | ISO 2-letter country code (MA, FR, …), recommended for a number in local format. |
- Required API key permission: status.
- carrier is the operator the number range was allocated to, not necessarily the one serving a ported number: for the serving operator, use GET /v1/lookup/:number. Errors: 400 MISSING_FIELD; 503 LOOKUP_UNAVAILABLE when the service is briefly unavailable.
curl -X POST "https://api.envoisms.ma/v1/verify/lookup" \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"number": "+212612345678"
}'{
"valid": true,
"number": "212612345678",
"local_format": "0612345678",
"international_format": "+212612345678",
"country_prefix": "+212",
"country_code": "MA",
"country_name": "Morocco",
"location": null,
"carrier": "Maroc Telecom",
"line_type": "mobile"
}List conversations
Returns the account’s WhatsApp and SMS threads, with each one’s 24-hour window state and overall counters.
| Parameter | Type | Requirement | Description |
|---|---|---|---|
| limit | integer | Optional | Number of results: 50 by default, at most 100. |
| offset | integer | Optional | Number of results to skip (pagination). Default: 0. |
| filter | string | Optional | all (default), unread, active_window or needs_human. |
| channel | string | Optional | all (default), whatsapp or sms. |
| phone_number_id | assigned_to | search | string | Optional | Connected WhatsApp number; assigned agent; search in the number, name and last message. |
- Required API key permission: send.
curl -X GET "https://api.envoisms.ma/v1/conversations?limit=50" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"data": [
{
"id": "th_212612345678",
"phone": "212612345678",
"contact_name": "Yassine Alami",
"channel": "whatsapp",
"lead_status": "new",
"unread_count": 1,
"bot_muted": false,
"handling_agent": null,
"notes": null,
"tags": [],
"last_inbound_at": "2026-10-22 08:42:00",
"last_outbound_at": "2026-10-21 17:10:00",
"last_message_preview": "Bonjour, la veste existe en taille L ?",
"last_message_role": "customer",
"last_message_at": "2026-10-22 08:42:00",
"is_window_active": true,
"window_expires_at": "2026-10-23T08:42:00.000Z",
"window_seconds_remaining": 84960,
"waba_name": "Ma Boutique",
"waba_phone": "+212522000000",
"phone_number_id": "109283746501928"
}
],
"total": 1,
"limit": 50,
"offset": 0,
"stats": {
"total_threads": 1,
"unread_threads": 1,
"active_window_threads": 1,
"human_takeover_threads": 0
}
}Retrieve a conversation
Returns one thread and its messages (at most 500), oldest first, with the 24-hour window state.
- Required API key permission: send.
- :phone accepts the number with or without +. role is customer for a received message, agent or bot for a sent one.
curl -X GET "https://api.envoisms.ma/v1/conversations/{phone}" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"thread": {
"id": "th_212612345678",
"phone": "212612345678",
"contact_name": "Yassine Alami",
"channel": "whatsapp",
"lead_status": "new",
"unread_count": 0,
"bot_muted": true,
"handling_agent": "Salma",
"notes": null,
"tags": [],
"last_inbound_at": "2026-10-22 08:42:00",
"last_outbound_at": "2026-10-22 08:45:10",
"is_window_active": true,
"window_expires_at": "2026-10-23T08:42:00.000Z",
"window_seconds_remaining": 84960,
"window": {
"is_window_active": true,
"window_expires_at": "2026-10-23T08:42:00.000Z",
"window_seconds_remaining": 84960
},
"waba_name": "Ma Boutique",
"waba_phone": "+212522000000",
"phone_number_id": "109283746501928"
},
"messages": [
{
"id": "wamsg_1c3e5a7c9e1b3d5f7a9c1e3b",
"phone": "212612345678",
"role": "customer",
"message": "Bonjour, la veste existe en taille L ?",
"wamid": "wamid.HBgLMjEyNjEyMzQ1Njc4FQIAEhgg",
"status": "read",
"media_type": "text",
"media_url": null,
"metadata": {},
"read_at": "2026-10-22 08:43:00",
"created_at": "2026-10-22 08:42:00"
}
]
}Reply in a conversation
Sends an agent reply (text, media or template) and pauses the bot on that thread. The send goes through the same path as POST /v1/messages: pricing, the opt-out list and the debit apply. Returns 201.
| Field | Type | Requirement | Description |
|---|---|---|---|
| message | string | Optional | Reply text, or the media caption. Required without a template or media (400 MISSING_MESSAGE). |
| channel | string | Optional | "whatsapp" (default) or "sms". |
| template_id | template_name | template_language | variables | mixed | Optional | Approved WhatsApp template to send instead of free text (by id, or by name and language), with its values (array or object). |
| media_url | media_type | filename | string | Optional | Attached media: https URL (or data URI), type image or document, document file name. |
| phone_number_id | string | Optional | Connected WhatsApp number that replies. |
- Required API key permission: send.
- A free-form WhatsApp message requires the contact to have written in the last 24 hours (otherwise 400 OUT_OF_24H_WINDOW); an approved template can go out at any time. Other errors: 403 WHATSAPP_NOT_CONNECTED, 404 TEMPLATE_NOT_FOUND, 400 TEMPLATE_NOT_APPROVED.
curl -X POST "https://api.envoisms.ma/v1/conversations/{phone}/messages" \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"message": "Thanks, we will call you back today."
}'{
"ok": true,
"message": {
"id": "msg_9e1b3d5f7a9c1e3b5d7f9a1c",
"phone": "212612345678",
"role": "agent",
"message": "Oui, la taille L est disponible.",
"wamid": "wamid.HBgLMjEyNjEyMzQ1Njc4FQIAERgS",
"status": "sent",
"channel": "whatsapp",
"media_type": "text",
"metadata": { "agent": "Salma" },
"created_at": "2026-10-22T08:45:10.221Z"
}
}List qualified leads
Returns the leads your WhatsApp bot recorded, newest first, with their score (0 to 100) and status. format=csv downloads the same list.
| Parameter | Type | Requirement | Description |
|---|---|---|---|
| limit | integer | Optional | Number of results: 100 by default, at most 500. |
| status | string | Optional | new, contacted, qualified, Fournisseur, won or lost. |
| preset | string | Optional | Bot sector (for example medical_equipment); supplier groups supplier enquiries. |
| search | string | Optional | Search in the number, name, category and notes. |
| format | string | Optional | json (default) or csv. |
- Any valid API key: no specific permission is required.
- No offset pagination: limit bounds the list. metrics is computed over the returned leads; hot counts those scoring 80 or more.
curl -X GET "https://api.envoisms.ma/v1/qualified-leads?limit=50" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"ok": true,
"leads": [
{
"id": "lead_3a5c7e9b1d3f5a7c9e1b3d5f",
"account_id": "acct_7b2e4c6a8d0f1e3b5c7a9d2f",
"phone_number_id": "109283746501928",
"contact_phone": "+212612345678",
"contact_name": "Cabinet Benjelloun",
"preset": "medical_equipment",
"category": "Échographe",
"bant_score": 85,
"urgency": "immediate",
"budget": "200 000 - 300 000 MAD",
"authority": "decision_maker",
"notes": null,
"source_type": "direct_inbound",
"status": "new",
"created_at": "2026-10-21 09:15:00",
"updated_at": "2026-10-21 09:15:00"
}
],
"metrics": { "total": 1, "hot": 1, "average_score": 85 }
}Update a lead
Changes a lead’s status, notes or name. Returns the updated lead.
| Field | Type | Requirement | Description |
|---|---|---|---|
| status | string | Optional | new, contacted, qualified, Fournisseur, won or lost (otherwise 400 INVALID_STATUS). |
| notes | string | Optional | Internal follow-up notes. |
| contact_name | string | Optional | Contact or business name. |
| value_minor | currency | integer | string | Optional | Deal value in minor units and its currency, passed to Meta with a move to won. |
- Any valid API key: no specific permission is required.
- A move to qualified or won is reported to Meta (QualifiedLead, Purchase) when the lead came from a click-to-WhatsApp ad; only on a real status change. 404 LEAD_NOT_FOUND when the lead does not belong to the account.
curl -X PATCH "https://api.envoisms.ma/v1/qualified-leads/{id}" \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"status": "contacted",
"notes": "Called back on 22/10"
}'{
"ok": true,
"lead": {
"id": "lead_3a5c7e9b1d3f5a7c9e1b3d5f",
"account_id": "acct_7b2e4c6a8d0f1e3b5c7a9d2f",
"phone_number_id": "109283746501928",
"contact_phone": "+212612345678",
"contact_name": "Cabinet Benjelloun",
"preset": "medical_equipment",
"category": "Échographe",
"bant_score": 85,
"urgency": "immediate",
"budget": "200 000 - 300 000 MAD",
"authority": "decision_maker",
"notes": "Rendez-vous fixé jeudi.",
"source_type": "direct_inbound",
"status": "contacted",
"created_at": "2026-10-21 09:15:00",
"updated_at": "2026-10-22 10:02:31",
"user_id": null
}
}List contacts
Returns the account’s contacts, with search and a list filter.
- Required API key permission: contacts.
curl -X GET "https://api.envoisms.ma/v1/contacts?limit=50" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"data": [
{
"id": "ctc_2d4f6a8c0e1b3d5f7a9c1e3b",
"account_id": "acct_7b2e4c6a8d0f1e3b5c7a9d2f",
"phone": "+212612345678",
"name": "Karim Bennani",
"email": "karim@example.ma",
"custom1": "VIP",
"custom2": null,
"custom3": null,
"opted_out": 0,
"opted_out_at": null,
"created_at": "2026-10-10 14:20:00"
}
],
"limit": 100,
"offset": 0,
"total": 1
}Create or update a contact
Creates a contact, or updates the one that already has this number (name, email and custom fields are replaced). Returns 201 with the contact.
| Field | Type | Requirement | Description |
|---|---|---|---|
| phone | string | Required | Number in E.164 format (otherwise 400 INVALID_PHONE). |
| name | email | string | Optional | Contact name and email. |
| list_id | string | Optional | Also adds the contact to this list (404 LIST_NOT_FOUND when it is not yours). |
| custom_fields | object | Optional | {"custom1", "custom2", "custom3"}: three free fields, usable in campaigns. |
- Required API key permission: contacts.
curl -X POST "https://api.envoisms.ma/v1/contacts" \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone": "+212612345678",
"name": "Karim Bennani",
"email": "karim@example.ma"
}'{
"id": "ctc_2d4f6a8c0e1b3d5f7a9c1e3b",
"account_id": "acct_7b2e4c6a8d0f1e3b5c7a9d2f",
"phone": "+212612345678",
"name": "Karim Bennani",
"email": "karim@example.ma",
"custom1": "VIP",
"custom2": null,
"custom3": null,
"opted_out": 0,
"opted_out_at": null,
"created_at": "2026-10-10 14:20:00"
}Import contacts
Queues a contact import, as JSON or as a CSV file, and returns 202 at once; the import then runs in batches of 100.
- Required API key permission: contacts.
- The response is given at queue time: total counts the rows received, and imported repeats it as is. Invalid numbers are skipped during the import; check the result with GET /v1/contacts. Other content types: 415 UNSUPPORTED_MEDIA_TYPE.
curl -X POST "https://api.envoisms.ma/v1/contacts/import" \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"contacts": [
{
"phone": "+212611111111",
"name": "Karim"
},
{
"phone": "+212622222222",
"name": "Salma"
}
]
}'{
"jobId": "job_4b6d8f0a2c4e6b8d0f2a4c6e",
"total": 150,
"imported": 150,
"skipped": 0,
"status": "queued"
}Delete a contact
Permanently deletes a contact. 404 NOT_FOUND when it does not exist.
- Required API key permission: contacts.
curl -X DELETE "https://api.envoisms.ma/v1/contacts/{id}" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"deleted": true,
"id": "ctc_2d4f6a8c0e1b3d5f7a9c1e3b"
}List contact lists
Returns the account’s contact lists, with their member count.
- Required API key permission: contacts.
curl -X GET "https://api.envoisms.ma/v1/contacts/lists" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"data": [
{
"id": "lst_f84b2d6a8c0e1f3b5d7a9c2e",
"account_id": "acct_7b2e4c6a8d0f1e3b5c7a9d2f",
"name": "Clients fidèles",
"description": "Clients ayant commandé au moins deux fois",
"count": 1420,
"created_at": "2026-09-15 09:00:00"
}
]
}Create a list
Creates an empty contact list, filled through POST /v1/contacts or /v1/contacts/import. Returns 201.
| Field | Type | Requirement | Description |
|---|---|---|---|
| name | string | Required | List name. |
| description | string | Optional | Free description. |
- Required API key permission: contacts.
curl -X POST "https://api.envoisms.ma/v1/contacts/lists" \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Newsletter",
"description": "Customers who subscribed at checkout"
}'{
"id": "lst_f84b2d6a8c0e1f3b5d7a9c2e",
"name": "Clients fidèles",
"description": "Clients ayant commandé au moins deux fois",
"count": 0
}Rename a list
Replaces a list’s name and description. 404 NOT_FOUND when it does not exist.
| Field | Type | Requirement | Description |
|---|---|---|---|
| name | string | Required | New name. |
| description | string | Optional | New description; when omitted, it is cleared. |
- Required API key permission: contacts.
curl -X POST "https://api.envoisms.ma/v1/contacts/lists/{id}" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"id": "lst_f84b2d6a8c0e1f3b5d7a9c2e",
"name": "Clients fidèles 2026",
"description": null
}Delete a list
Deletes a list and its memberships; the contacts themselves are kept. Refused (400 LIST_IN_USE) while a scheduled, running or paused campaign uses it.
- Required API key permission: contacts.
curl -X DELETE "https://api.envoisms.ma/v1/contacts/lists/{id}" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"deleted": true,
"id": "lst_f84b2d6a8c0e1f3b5d7a9c2e"
}List opt-outs
Returns the numbers opted out of your sends. No message goes to them (403 OPTED_OUT).
- Required API key permission: optouts.
curl -X GET "https://api.envoisms.ma/v1/optouts?limit=50" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"data": [
{ "phone": "+212611111111", "opted_out_at": "2026-10-01 12:00:00" }
],
"limit": 100,
"offset": 0,
"total": 1
}Add an opt-out
Adds a number to your opt-out list. Returns 201.
| Field | Type | Requirement | Description |
|---|---|---|---|
| phone | string | Required | Number in E.164 format. |
- Required API key permission: optouts.
curl -X POST "https://api.envoisms.ma/v1/optouts" \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone": "+212612345678"
}'{
"phone": "+212611111111",
"opted_out": true
}Remove an opt-out
Removes a number from the opt-out list. Do it only on the person’s fresh agreement, to be recorded with POST /v1/consents.
- Required API key permission: optouts.
curl -X DELETE "https://api.envoisms.ma/v1/optouts/{phone}" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"phone": "+212611111111",
"opted_out": false
}List consent events
Append-only consent ledger (Law 09-08): every grant and every withdrawal is a dated event, with its source and evidence. The platform itself records the STOP/START keywords it receives and the first conversation a customer opens.
| Parameter | Type | Requirement | Description |
|---|---|---|---|
| limit | integer | Optional | Number of results: 100 by default, at most 500. |
| offset | integer | Optional | Number of results to skip (pagination). Default: 0. |
| phone | string | Optional | E.164 number. |
| purpose | string | Optional | marketing, transactional, otp or service. |
| status | string | Optional | granted or withdrawn. |
| from | to | string | Optional | ISO 8601 bounds on recorded_at. |
| format | string | Optional | csv: downloads the whole filtered set. |
- Required API key permission: contacts.
curl -X GET "https://api.envoisms.ma/v1/consents?limit=50" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"data": [
{
"id": "cns_9f2a4c6e8b0d1f3a5c7e9b2d",
"phone": "+212612345678",
"channel": "whatsapp",
"purpose": "marketing",
"status": "withdrawn",
"source": "whatsapp_keyword",
"evidence": { "keyword": "stop", "lang": "fr" },
"recorded_at": "2026-10-05T09:00:00.000Z",
"created_at": "2026-10-05T09:00:00.000Z"
}
],
"limit": 100,
"offset": 0,
"total": 1
}Record a consent event
Records that a person granted or withdrew consent, with the evidence you hold. A marketing withdrawal puts the number on the opt-out list at once. Returns 201.
| Field | Type | Requirement | Description |
|---|---|---|---|
| phone | string | Required | Number in E.164 format. |
| purpose | string | Required | marketing, transactional, otp or service. |
| status | string | Optional | granted (default) or withdrawn. |
| channel | string | Optional | whatsapp, sms or any (default). |
| source | string | Optional | api (default), form, import or dashboard. Keyword sources are reserved to the platform. |
| evidence | object | Optional | Free JSON object under 4,096 bytes: wording shown, URL, IP, reference. |
| recorded_at | string | Optional | ISO 8601 time of the act (default: now); may be backdated for an import, never in the future. |
- Required API key permission: contacts.
- Errors: 400 INVALID_PHONE, INVALID_PURPOSE, INVALID_STATUS, INVALID_CHANNEL, INVALID_SOURCE, INVALID_EVIDENCE, INVALID_DATE.
curl -X POST "https://api.envoisms.ma/v1/consents" \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone": "+212612345678",
"purpose": "marketing",
"status": "granted",
"channel": "whatsapp",
"source": "form",
"evidence": {
"form": "checkout",
"ip": "196.200.1.4"
}
}'{
"id": "cns_1b3d5f7a9c2e4b6d8f0a2c4e",
"phone": "+212612345678",
"channel": "any",
"purpose": "marketing",
"status": "granted",
"source": "form",
"evidence": { "text": "J’accepte de recevoir les offres par WhatsApp", "url": "https://example.ma/inscription" },
"recorded_at": "2026-10-01T10:00:00.000Z",
"created_at": "2026-10-01T10:00:00.000Z"
}Consent state and history of a number
Returns the current state per purpose (from the newest event; unknown without history, never assumed granted), opt-out list membership and the history (at most 500 events).
- Required API key permission: contacts.
curl -X GET "https://api.envoisms.ma/v1/consents/{phone}" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"phone": "+212612345678",
"opted_out": true,
"opted_out_at": "2026-10-05T09:00:00.000Z",
"state": {
"marketing": { "status": "withdrawn", "channel": "whatsapp", "source": "whatsapp_keyword", "recorded_at": "2026-10-05T09:00:00.000Z", "record_id": "cns_9f2a4c6e8b0d1f3a5c7e9b2d" },
"transactional": { "status": "unknown" },
"otp": { "status": "unknown" },
"service": { "status": "granted", "channel": "whatsapp", "source": "whatsapp_inbound", "recorded_at": "2026-07-01T09:00:00.000Z", "record_id": "cns_0a2c4e6b8d1f3a5c7e9b0d2f" }
},
"history": []
}List campaigns
Returns the account’s campaigns, newest first, with their counters.
- Required API key permission: campaigns.
- Statuses: draft, scheduled, running, paused, completed, failed, cancelled.
curl -X GET "https://api.envoisms.ma/v1/campaigns?limit=50" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"data": [
{
"id": "cmp_8d2a4c6e1b3f5a7c9e0b2d4f",
"account_id": "acct_7b2e4c6a8d0f1e3b5c7a9d2f",
"name": "Soldes d’automne",
"channel": "sms",
"list_id": "lst_f84b2d6a8c0e1f3b5d7a9c2e",
"template_id": null,
"body": "Bonjour {{name}}, -30% sur la collection avec le code AUT30.",
"sender_id": "MABOUTIQUE",
"status": "completed",
"scheduled_at": null,
"started_at": "2026-10-15 10:00:00",
"completed_at": "2026-10-15 10:06:12",
"total_count": 1420,
"sent_count": 1412,
"delivered_count": 1380,
"failed_count": 8,
"cost_eur": 102.24,
"cost_mad": 1124.64,
"created_at": "2026-10-14 16:30:00",
"buttons": null,
"template_variables": null
}
],
"total": 1,
"limit": 50,
"offset": 0
}Create a campaign
Creates a campaign as a draft, or scheduled when scheduled_at is given. Nothing goes out before POST /v1/campaigns/:id/send. Returns 201.
| Field | Type | Requirement | Description |
|---|---|---|---|
| name | string | Required | Campaign name. |
| body | string | Optional | (SMS) Message text. body or template_id is required. |
| template_id | string | Optional | Template of the account. Required on WhatsApp, where Meta must have approved it (400 TEMPLATE_NOT_APPROVED). |
| channel | string | Optional | "sms" (default) or "whatsapp" (requires a connected WhatsApp Business number, otherwise 403 WHATSAPP_NOT_CONNECTED). |
| list_id | string | Optional | Recipient list; needed to launch the campaign (404 LIST_NOT_FOUND when it is not yours). |
| sender_id | string | Optional | SMS: Sender ID (same rules as POST /v1/messages; default EnvoiSMS). WhatsApp: connected number that sends (default: your default number; 400 INVALID_WABA_SENDER when it is not yours). |
| scheduled_at | string | Optional | ISO 8601 date; sets the campaign to scheduled. |
| template_variables | object | Optional | (WhatsApp) Value of each template parameter, by key. Key "1", "2", … or a variable name: a contact field (name, phone, email, custom1, custom2, custom3) or fixed text prefixed "static:". "_header_media_url": https link of the header media (required for an image, video or document header). "_button_<i>": the variable part of the URL button at index i (from 0). Without a value, {{1}} reads name, {{2}} custom1 and {{3}} custom2. |
| consent_attested | boolean | Optional | (WhatsApp, marketing template) true: you attest that every contact on the list agreed to receive your WhatsApp messages. Required for a marketing template (otherwise 400 CONSENT_ATTESTATION_REQUIRED); the attestation is timestamped. |
| buttons | array | Optional | (WhatsApp) Buttons, same format as POST /v1/messages. Refused on SMS (400 BUTTONS_NOT_SUPPORTED). |
- Required API key permission: campaigns.
- WhatsApp: everything the template needs per contact is checked at creation; what is missing is named in 400 MISSING_TEMPLATE_PARAMS. Other errors: 400 MISSING_TEMPLATE, 404 TEMPLATE_NOT_FOUND.
curl -X POST "https://api.envoisms.ma/v1/campaigns" \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Summer sale",
"channel": "sms",
"body": "Summer sale: -20% until Sunday. Reply STOP to unsubscribe.",
"list_id": "lst_…"
}'{
"id": "cmp_8d2a4c6e1b3f5a7c9e0b2d4f",
"status": "draft"
}Retrieve a campaign
Returns one campaign, its status and its send counters. 404 NOT_FOUND otherwise.
- Required API key permission: campaigns.
curl -X GET "https://api.envoisms.ma/v1/campaigns/{id}" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"id": "cmp_8d2a4c6e1b3f5a7c9e0b2d4f",
"account_id": "acct_7b2e4c6a8d0f1e3b5c7a9d2f",
"name": "Nouvelle collection",
"channel": "whatsapp",
"list_id": "lst_f84b2d6a8c0e1f3b5d7a9c2e",
"template_id": "tpl_8e2a4c6b1d3f5a7c9e0b2d4f",
"body": null,
"sender_id": "+212522000000",
"status": "running",
"scheduled_at": null,
"started_at": "2026-10-22 10:00:00",
"completed_at": null,
"total_count": 1420,
"sent_count": 840,
"delivered_count": 790,
"failed_count": 3,
"cost_eur": 0,
"cost_mad": 0,
"created_at": "2026-10-21 18:12:40",
"buttons": null,
"template_variables": "{\"1\":\"name\"}"
}Update a campaign
Updates a draft or scheduled campaign; only the fields you send change. Returns the updated campaign.
| Field | Type | Requirement | Description |
|---|---|---|---|
| name | body | sender_id | scheduled_at | string | Optional | New name, text, sender or scheduled date (ISO 8601). An empty value leaves the field unchanged. |
- Required API key permission: campaigns.
- 409 CAMPAIGN_NOT_EDITABLE when the campaign is neither draft nor scheduled; 404 NOT_FOUND when it does not exist.
curl -X PATCH "https://api.envoisms.ma/v1/campaigns/{id}" \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"scheduled_at": "2026-11-01T09:00:00Z"
}'{
"id": "cmp_8d2a4c6e1b3f5a7c9e0b2d4f",
"account_id": "acct_7b2e4c6a8d0f1e3b5c7a9d2f",
"name": "Soldes d’automne — relance",
"channel": "sms",
"list_id": "lst_f84b2d6a8c0e1f3b5d7a9c2e",
"template_id": null,
"body": "Dernier jour : -30% avec le code AUT30.",
"sender_id": "MABOUTIQUE",
"status": "scheduled",
"scheduled_at": "2026-10-25T09:00:00Z",
"started_at": null,
"completed_at": null,
"total_count": 0,
"sent_count": 0,
"delivered_count": 0,
"failed_count": 0,
"cost_eur": 0,
"cost_mad": 0,
"created_at": "2026-10-22 11:05:00",
"buttons": null,
"template_variables": null
}Cancel a campaign
Sets the campaign to cancelled; it can still be retrieved. A running campaign must be paused first (409 CAMPAIGN_RUNNING).
- Required API key permission: campaigns.
curl -X DELETE "https://api.envoisms.ma/v1/campaigns/{id}" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"deleted": true,
"id": "cmp_8d2a4c6e1b3f5a7c9e0b2d4f"
}Launch a campaign
Launches the campaign to the members of its list who have not opted out. Sending runs in the background; follow it with GET /v1/campaigns/:id. Returns 202.
- Required API key permission: campaigns.
- Errors: 400 ALREADY_RUNNING, 400 MISSING_LIST, 400 EMPTY_LIST (no eligible recipient), 400 MISSING_BODY, 404 NOT_FOUND. Each message is priced and debited like a single send.
curl -X POST "https://api.envoisms.ma/v1/campaigns/{id}/send" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"id": "cmp_8d2a4c6e1b3f5a7c9e0b2d4f",
"status": "running",
"total": 1420
}Pause a campaign
Pauses a running campaign. 409 NOT_RUNNING when it is not running.
- Required API key permission: campaigns.
curl -X POST "https://api.envoisms.ma/v1/campaigns/{id}/pause" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"paused": true,
"id": "cmp_8d2a4c6e1b3f5a7c9e0b2d4f"
}Resume a campaign
Resumes a paused campaign where it stopped. 409 NOT_PAUSED when it is not paused.
- Required API key permission: campaigns.
curl -X POST "https://api.envoisms.ma/v1/campaigns/{id}/resume" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"resumed": true,
"id": "cmp_8d2a4c6e1b3f5a7c9e0b2d4f"
}List webhooks
Returns your active webhooks. The secret appears only masked (secret_hint); it is shown in full only at creation and rotation.
- Required API key permission: webhooks.
curl -X GET "https://api.envoisms.ma/v1/webhooks" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"data": [
{
"id": "whk_5f2b7d9a1c3e5b7d9f1a3c5e",
"name": "Webhook Endpoint",
"target_type": "custom",
"format": "standard",
"url": "https://example.ma/hooks/envoisms",
"events": "[\"message.delivered\",\"message.failed\"]",
"active": 1,
"last_triggered_at": "2026-10-22 09:14:09",
"last_status": 200,
"created_at": "2026-10-01 10:00:00",
"secret_hint": "••••9c2e"
}
]
}Create a webhook
Registers an HTTPS URL to receive the chosen events. Returns 201 with the signing secret, shown this one time.
| Field | Type | Requirement | Description |
|---|---|---|---|
| url | string | Required | Public HTTPS URL (otherwise 400 INVALID_URL). |
| events | string[] | Optional | message.sent, message.delivered, message.read, message.failed, message.undeliverable, message.fallback, message.inbound, message.reaction, contact.optout, contact.optin, lead.qualified; wildcards "message.*" and "*". Default: ["message.delivered", "message.failed"]. |
| secret | string | Optional | Signing secret; generated (sec_…) when omitted. |
| name | format | string | Optional | Label; format standard ({"event", "data", "timestamp"}) or flat (data fields also at the top level, for a spreadsheet). |
- Required API key permission: webhooks.
- Signature: every delivery carries X-EnvoiSMS-Signature-256: t=<unix time>,v1=<hex HMAC-SHA256 of "t.body" with the secret>, and X-EnvoiSMS-Event. Reject a timestamp older than 5 minutes. A failure (5xx, 429 or network) is retried after 60 s, 10 min, then 1 h: 4 attempts in all.
- The number of active webhooks depends on the plan (Starter 2, Business 5, Pro 15, Enterprise 50); beyond it, 403 LIMIT_EXCEEDED.
curl -X POST "https://api.envoisms.ma/v1/webhooks" \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.ma/webhooks/envoisms",
"events": [
"message.delivered",
"message.failed",
"message.inbound"
]
}'{
"id": "whk_5f2b7d9a1c3e5b7d9f1a3c5e",
"name": "Webhook Endpoint",
"target_type": "custom",
"format": "standard",
"url": "https://example.ma/hooks/envoisms",
"events": ["message.delivered", "message.failed"],
"secret": "sec_3d5f7a9c1e3b5d7f9a1c9c2e",
"active": true
}Update a webhook
Updates a webhook’s URL, events, name, format or active state; only the fields you send change.
| Field | Type | Requirement | Description |
|---|---|---|---|
| url | events | name | format | mixed | Optional | Same rules as at creation. |
| active | boolean | Optional | false stops deliveries to this webhook. |
- Required API key permission: webhooks.
curl -X PATCH "https://api.envoisms.ma/v1/webhooks/{id}" \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"active": false
}'{
"updated": true,
"id": "whk_5f2b7d9a1c3e5b7d9f1a3c5e"
}Delete a webhook
Deactivates the webhook: no further event is sent to it and it leaves the list.
- Required API key permission: webhooks.
curl -X DELETE "https://api.envoisms.ma/v1/webhooks/{id}" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"deleted": true,
"id": "whk_5f2b7d9a1c3e5b7d9f1a3c5e"
}Test a webhook
Sends a sample event, signed like a real one, to the webhook’s URL. Returns 202 with the sample sent.
| Field | Type | Requirement | Description |
|---|---|---|---|
| event | string | Optional | Event name to simulate (default: lead.qualified). The sample data is an inbound message for message.inbound, a qualified lead otherwise. |
- Required API key permission: webhooks.
curl -X POST "https://api.envoisms.ma/v1/webhooks/{id}/test" \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"event": "message.delivered"
}'{
"queued": true,
"id": "whk_5f2b7d9a1c3e5b7d9f1a3c5e",
"event": "message.inbound",
"sample": {
"event": "message.inbound",
"data": {
"message_id": "msg_demo_k3x9f2ab",
"from": "+212612345678",
"to": "+212708789095",
"channel": "whatsapp",
"text": "Bonjour, je souhaite des informations sur vos tarifs et solutions.",
"contact_name": "Karim Bennani",
"timestamp": "2026-10-22T09:30:00.000Z"
},
"timestamp": "2026-10-22T09:30:00.000Z"
}
}Rotate a webhook secret
Replaces the signing secret and returns the new one, this one time. Deliveries already queued stay signed with the old one: accept both for a few minutes.
- Required API key permission: webhooks.
curl -X POST "https://api.envoisms.ma/v1/webhooks/{id}/rotate-secret" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"id": "whk_5f2b7d9a1c3e5b7d9f1a3c5e",
"secret": "sec_7a9c1e3b5d7f9a1c3e5b0d2f",
"rotated": true
}Webhook delivery log
Returns the last 100 delivery attempts, newest first, with the body sent and your server’s response.
| Parameter | Type | Requirement | Description |
|---|---|---|---|
| webhook_id | string | Optional | Only deliveries of this webhook. |
- Required API key permission: webhooks.
curl -X GET "https://api.envoisms.ma/v1/webhooks/logs" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"data": [
{
"id": "0b6f3c2e-8a4d-4f1b-9c7e-2d5a1f8e6b30",
"webhook_id": "whk_5f2b7d9a1c3e5b7d9f1a3c5e",
"event": "message.delivered",
"url": "https://example.ma/hooks/envoisms",
"payload": "{\"event\":\"message.delivered\",\"data\":{\"id\":\"msg_3f9a1c7e5b2d4a6f8e0c1b3d\",\"to\":\"+212612345678\",\"channel\":\"sms\",\"status\":\"delivered\"},\"timestamp\":\"2026-10-22T09:14:09.120Z\"}",
"status_code": 500,
"error_message": null,
"attempt": 1,
"delivered": 0,
"created_at": "2026-10-22 09:14:09"
}
],
"max_attempts": 4
}Replay a webhook delivery
Resends a logged delivery — same event, same data, a fresh signature — to the webhook’s current URL, with the usual retries. Returns 202.
- Required API key permission: webhooks.
- 404 NOT_FOUND when the log is unknown or its webhook was deleted; 422 REPLAY_UNAVAILABLE when the body sent was not kept.
curl -X POST "https://api.envoisms.ma/v1/webhooks/logs/{log_id}/replay" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"queued": true,
"replay_of": "0b6f3c2e-8a4d-4f1b-9c7e-2d5a1f8e6b30",
"webhook_id": "whk_5f2b7d9a1c3e5b7d9f1a3c5e",
"event": "message.delivered"
}List inbound webhooks
Returns your inbound webhooks: catch URLs that turn a call from a third-party tool (form, Zapier, Make, …) into a sent message.
- Any valid API key: no specific permission is required.
curl -X GET "https://api.envoisms.ma/v1/inbound-webhooks" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"data": [
{
"id": "inwh_9a8b7c6d5e4f3a2b1c0d9e8f",
"name": "Formulaire de contact",
"channel": "whatsapp",
"template_id": "tpl_4c1e9a7b3d5f2e8a6c0b1d3f",
"active": 1,
"created_at": "2026-10-12 14:00:00"
}
]
}Create an inbound webhook
Creates a catch URL, POST /v1/inbound-webhooks/catch/:id: each JSON call it receives sends a message to the number read from the body. Returns 201.
| Field | Type | Requirement | Description |
|---|---|---|---|
| name | string | Required | Source label. |
| channel | string | Required | sms or whatsapp. |
| mapping_config | object | Required | {"to_path": "contact.phone", "variables": {"1": "contact.name"}}: path of the number in the received JSON, and path of each template variable. |
| template_id | string | Optional | Template to send (id or name). |
- Any valid API key: no specific permission is required.
- The catch URL takes no key: its id acts as the secret, so do not publish it. It accepts at most 64 KB and 120 calls per minute, returns 202 {"success": true, "messageId"} and debits your balance like a normal send. Delete with DELETE /v1/inbound-webhooks/:id.
curl -X POST "https://api.envoisms.ma/v1/inbound-webhooks" \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Website form",
"channel": "whatsapp",
"mapping_config": {
"to_path": "phone"
}
}'{
"id": "inwh_9a8b7c6d5e4f3a2b1c0d9e8f"
}Retrieve the balance
Returns the prepaid balance in dirhams and euros, the plan and its expiry date.
- Required API key permission: balance.
- GET /v1/balance returns the first four fields with the same permission.
curl -X GET "https://api.envoisms.ma/v1/billing/balance" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"balance_mad": 1492.5,
"balance_eur": 135.68,
"primary_currency": "MAD",
"plan": "business",
"plan_expires_at": "2026-12-31 23:59:59",
"plan_days_left": 70
}List transactions
Returns the balance movements, newest first: top-ups, usage, refunds, adjustments and grants, each with the balance after it.
- Required API key permission: billing.
curl -X GET "https://api.envoisms.ma/v1/billing/transactions?limit=50" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"data": [
{
"id": "tx_6e8a0c2e4b6d8f0a2c4e6b8d",
"type": "usage",
"status": "completed",
"amount_eur": -0.072,
"amount_mad": -0.792,
"balance_after_eur": 135.68,
"balance_after_mad": 1492.5,
"payment_method": null,
"description": "SMS +212612345678",
"reference": "msg_3f9a1c7e5b2d4a6f8e0c1b3d",
"stripe_invoice_id": null,
"created_at": "2026-10-22 09:14:03"
}
],
"limit": 50,
"offset": 0,
"total": 1,
"type": null
}Machine payments status
Says whether an agent without an account can pay for API access with a USDC deposit (HTTP 402, MPP protocol), and on what terms. No authentication.
- When disabled, the response is {"enabled": false}.
- Flow: call /v1/messages, /v1/waba, /v1/verify or /v1/mcp with no Authorization header and with X-Payment-Method: mpp (or Accept: application/problem+json); the response is 402 with a WWW-Authenticate: Payment header describing the deposit. Deposit deposit_usd in USDC, then repeat the call with Authorization: Payment <credential>. The response carries the issued API key in X-Machine-Session-Key (once) and the receipt in Payment-Receipt; use that key as a Bearer key from then on.
- Without X-Payment-Method or Accept: application/problem+json, an unauthenticated call gets 401 UNAUTHORIZED. A deposit not yet settled gives 402 PAYMENT_NOT_SETTLED; an unreadable credential, 400 INVALID_CREDENTIAL. The issued key holds only the send, status, balance and verify permissions. At most 5 challenges per minute per IP.
curl -X GET "https://api.envoisms.ma/v1/machine-payments/status" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"enabled": true,
"deposit_usd": 5,
"credit_mad": 46.5,
"networks": ["base", "tempo"],
"permissions": ["send", "status", "balance", "verify"]
}Sending statistics
Returns, over a window of days, volumes, confirmed delivery, the spend debited and their breakdown by channel, operator, status and day.
| Parameter | Type | Requirement | Description |
|---|---|---|---|
| days | integer | Optional | Window size, 30 by default, at most 365. |
| campaign_id | string | Optional | Statistics of one campaign (the response then omits error_diagnostics and waba_breakdown). |
- Required API key permission: analytics.
- delivery_rate is computed over messages whose delivery was confirmed or denied only; coverage_pct says what share of messages got that confirmation. An unknown value is null, never 100. cost_* is what was debited, net of refunds. The response is cached for 60 seconds (X-Cache header).
curl -X GET "https://api.envoisms.ma/v1/analytics" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"window_days": 30,
"campaign_id": null,
"summary": {
"total": 420,
"delivered": 398,
"failed": 6,
"unconfirmed": 16,
"delivery_rate": 98.51,
"coverage_pct": 96.19,
"cost_eur": 30.24,
"cost_mad": 332.64,
"avg_latency": 4.2
},
"channels": [
{ "channel": "sms", "total": 420, "cost_eur": 30.24, "cost_mad": 332.64, "fallback_sends": 0 }
],
"operators": [
{ "operator": "Maroc Telecom", "total": 250, "delivered": 240, "failed": 2, "unconfirmed": 8, "cost_eur": 18, "cost_mad": 198, "delivery_rate": 99.17, "coverage_pct": 96.8, "avg_latency": null }
],
"status_distribution": [
{ "status": "delivered", "total": 398 },
{ "status": "unconfirmed", "total": 16 },
{ "status": "failed", "total": 6 }
],
"daily": [
{ "day": "2026-10-21", "total": 14, "sms_total": 14, "whatsapp_total": 0, "delivered": 13, "failed": 0, "unconfirmed": 1, "cost_eur": 1.008, "cost_mad": 11.088 }
],
"error_diagnostics": [],
"waba_breakdown": []
}List API keys
Returns the account’s API keys, active and revoked, never with their full value.
- Required API key permission: api_keys.
- permissions and ip_whitelist are JSON strings (ip_whitelist is null when unrestricted).
curl -X GET "https://api.envoisms.ma/v1/api-keys" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"data": [
{
"id": "key_e84c2a6b8d0f1e3c5a7b9d2f",
"name": "Serveur de production",
"key_prefix": "smr_a8f7d6c5",
"ip_whitelist": "[\"196.200.1.4\"]",
"rate_limit": 180,
"permissions": "[\"send\",\"verify\",\"status\",\"balance\"]",
"active": 1,
"last_used_at": "2026-10-22 09:14:03",
"created_at": "2026-10-01 08:00:00",
"sandbox": false
}
]
}Create an API key
Creates a live (smr_…) or sandbox (env_test_…) key. Returns 201; the full value (api_key) is shown once.
| Field | Type | Requirement | Description |
|---|---|---|---|
| name | string | Optional | Key label (default: "API key"). |
| permissions | string[] | Optional | Among send, verify, status, balance, contacts, campaigns, webhooks, billing, analytics, api_keys, optouts, sender_id. Default: send, verify, status, balance. Unknown values are ignored. |
| ip_whitelist | string[] | Optional | The only IP addresses allowed to use the key. |
| rate_limit | integer | Optional | Requests per minute, 10 to 2000. Default by plan: Starter 60, Business 180, Pro 500, Enterprise 1,200. |
| sandbox | boolean | Optional | true creates a test key: requests are validated and recorded, nothing is sent, nothing is billed. A key’s mode is permanent. |
- Required API key permission: api_keys.
- The number of active live keys depends on the plan, and sandbox keys have their own cap; beyond it, 403 LIMIT_EXCEEDED. A sandbox key adds "note" to the response.
curl -X POST "https://api.envoisms.ma/v1/api-keys" \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "OTP backend",
"permissions": [
"verify",
"status"
],
"sandbox": true
}'{
"id": "key_e84c2a6b8d0f1e3c5a7b9d2f",
"name": "Serveur de production",
"key_prefix": "smr_a8f7d6c5",
"api_key": "smr_a8f7d6c5…",
"permissions": ["send", "verify", "status", "balance"],
"rate_limit": 180,
"sandbox": false,
"warning": "The full API key is shown once. Store it securely."
}Update an API key
Updates a key’s name, permissions, allowed IPs, rate limit or active state.
| Field | Type | Requirement | Description |
|---|---|---|---|
| name | permissions | ip_whitelist | mixed | Optional | Same rules as at creation. |
| rate_limit | integer | Optional | Requests per minute, 10 to 1000. |
| active | boolean | Optional | false suspends the key. |
- Required API key permission: api_keys.
- Sandbox or live mode cannot change: 400 SANDBOX_IMMUTABLE. Create a new key.
curl -X PATCH "https://api.envoisms.ma/v1/api-keys/{id}" \
-H "Authorization: Bearer $ENVOISMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "OTP backend (rotated)"
}'{
"updated": true,
"id": "key_e84c2a6b8d0f1e3c5a7b9d2f"
}Revoke an API key
Permanently revokes a key: it no longer authenticates any request.
- Required API key permission: api_keys.
curl -X DELETE "https://api.envoisms.ma/v1/api-keys/{id}" \
-H "Authorization: Bearer $ENVOISMS_API_KEY"{
"revoked": true,
"id": "key_e84c2a6b8d0f1e3c5a7b9d2f"
}