Nevorai Connect: Send API
Lets a client's own website or app send one approved template from the client's own number, e.g. a love-report PDF right after payment (Sadhna Astro), a class link after sign-up (Landing Page OS), a receipt.
- The key is made by the business owner: Connect → Connectors → Connect API → New key. It is shown once. Keep it on the server only (an env var such as
CONNECT_API_KEY), never in browser code. - Meta charges the client's own WABA per message, the same as a dashboard send.
- Every send also appears in the client's Connect inbox.
- The template must be approved in Connect → Templates. Its name and blanks decide what the request must contain.
Send
POST https://connect.nevorai.com/api/v1/send
Authorization: Bearer nck_…
Content-Type: application/json
{
"to": "919876543210", // country code + number; a bare 10-digit number is taken as Indian
"template": "love_report", // approved template name
"language": "en", // only needed if the name exists in several languages
"variables": ["Ravi", "Priya"], // {{1}}, {{2}} … in the template text, in order
"header": { "document": "https://…/report.pdf", "filename": "Love report.pdf" },
// or {"image": "https://…"} / {"video": "https://…"} / {"text": "…"}
// only if the template has a header; links must be public https
// leave it out to send the photo/video/PDF saved with the template in Connect
"buttons": ["ORDER123"], // the {{1}} at the end of each link button, in order
"ref": "order_123" // your own id; the same ref never sends twice
}Answer (200):
{ "id": "7d1c…", "ref": "order_123", "to": "919876543210", "template": "love_report",
"status": "sent", "error": null, "message_id": "wamid.HBg…", "created_at": "2026-09-27T…" }- Same
refagain: the first send comes back with"duplicate": true, and nothing is sent. The exception is when the first send failed; then it is tried again. - Errors look like
{ "error": { "code": "wrong_variables", "message": "…" } }.
| HTTP | code | meaning |
|---|---|---|
| 400 | bad_request | the body is malformed (the message says which field) |
| 400 | header_required / header_not_allowed | the header doesn't match the template |
| 400 | wrong_variables / wrong_buttons | the count doesn't match the template's blanks |
| 400 | language_required | the template exists in several languages |
| 401 | unauthorized | the key is missing, wrong or revoked |
| 403 | paused | the business is paused |
| 404 | template_not_found | there is no approved template with that name on the number |
| 409 | opted_out | the customer replied STOP |
| 409 | number_not_connected | there is no connected number |
| 429 | rate_limited | more than 120 requests a minute on one key |
| 502 | meta_error | Meta refused the send (the message has Meta's reason). The send row is failed, and the same ref may be retried |
Status
GET /api/v1/messages?id=<id> or ?ref=<your ref> (same header) returns the same shape with the latest status: sending → sent → delivered → read, or failed (with error).
Callback (optional): give the key a status address when making it. On every change Connect POSTs:
{ "event": "message.status", "id": "7d1c…", "ref": "order_123", "to": "919876543210",
"template": "love_report", "status": "delivered", "error": null, "message_id": "wamid…", "at": "…" }with the header X-Connect-Signature: sha256=<HMAC-SHA256 of the raw body, keyed by the status secret>. Check the signature, then answer 2xx within 5 s. There is one try per status; if you miss one, ask GET /api/v1/messages.
Follow-ups
A follow-up is a set of approved templates that send themselves, each at its own time: right after joining, N minutes later, on day N at a clock time, or N minutes before/after the person's own event time (a webinar, class, appointment, due date). The owner builds it in Connect → Follow-ups; the Website tab there shows a ready-to-copy request. Its API name (key, e.g. webinar-reminders) is what the website sends.
List what the business has, and what each needs per person:
GET https://connect.nevorai.com/api/v1/followups
Authorization: Bearer nck_…{ "followups": [ { "followup": "webinar-reminders", "name": "Webinar reminders", "enabled": true, "messages": 4,
"needs_event_at": true,
"fields": [ { "key": "topic", "label": "Webinar name", "required": false, "default": "Meta Ads for beginners" },
{ "key": "link", "label": "Joining link", "required": true, "default": null } ] } ] }Start one person (a sign-up, a payment, a new lead):
POST https://connect.nevorai.com/api/v1/followups/start
{
"followup": "webinar-reminders", // the API name
"to": "919876543210", // country code + number; a bare 10-digit number is taken as Indian
"name": "Ravi Kumar", // optional; fills "their name" blanks and names a new contact
"event_at": "2026-10-05T19:00", // India time, or with a zone (…T19:00:00+05:30). Needed when a message is timed around the event
"fields": { "link": "https://…" }, // per-person details; a detail with a default may be left out
"ref": "signup-1042" // your own id; the same ref again returns the first one
}Answer (200): { "id", "followup", "to", "ref", "status": "active", "duplicate": false, "messages": [ { "template", "at", "status": "pending" | "sent" | "skipped", "note" } ] }. A message whose time has already passed (e.g. "1 hour before" for someone who joins 20 minutes before) comes back skipped with a note, and is never sent late.
- Same number again with another
ref: their earlier place is replaced (e.g. a sign-up for the next date). - A number not yet in Contacts is added. Anyone who replied STOP is refused.
- A reply from the person ends the follow-up if "Stop when they reply" is on; STOP always ends every follow-up.
Stop one person, e.g. once they have paid:
POST https://connect.nevorai.com/api/v1/followups/stop
{ "to": "919876543210", "followup": "fee-due" } // leave out "followup" to stop all of theirsAnswer: { "to", "followup", "stopped": 1 } (how many places were ended; 0 is not an error).
| HTTP | code | meaning |
|---|---|---|
| 400 | bad_request / bad_event_at | the body is malformed (the message says which field) |
| 400 | event_at_required | a message is timed around the event: send event_at |
| 400 | fields_required | a detail has no default: send it in fields (the message names them) |
| 404 | followup_not_found | no follow-up with that API name |
| 409 | followup_off / followup_empty | it is switched off, or has no messages yet |
| 409 | opted_out | the customer replied STOP |
The key errors (401, 403 paused, 429) are the same as for sends. Every follow-up message appears in the client's inbox like any other send.
Contacts
Adds or updates one customer, e.g. from a sign-up form, so the business can tag them and send targeted broadcasts (Connect → Broadcasts → New → "Only people with these tags").
POST https://connect.nevorai.com/api/v1/contacts
Authorization: Bearer nck_…
Content-Type: application/json
{
"phone": "919876543210", // country code + number; a bare 10-digit number is taken as Indian
"name": "Ravi Kumar", // optional; a blank never wipes a saved name
"tags": ["webinar-oct", "lead"], // optional; ADDED to their tags (up to 20 per person, 30 letters each)
"opted_in": true // optional; true only if they ticked "send me updates on WhatsApp"
}{ "phone": "919876543210", "name": "Ravi Kumar", "tags": ["webinar-oct", "lead"], "opted_in": true, "created": true }201for a new contact,200for an existing one.- A tag takes the spelling the business already uses (
VIP, notvip). - Someone who replied STOP stays off broadcasts:
opted_instaysfalseand the answer has"replied_stop": true. - Errors:
400 invalid_phone,400 invalid_tags; the key errors (401, 403paused, 429) are the same as for sends.
Stages
Tells Connect where a customer is on the way to buying: replied, qualified, booked, bought, not_interested, no_show. Connect keeps the stage on the chat, shows it in the inbox, and, for a customer who came from a Meta lead form, sends it back to the app that sent the lead (AdsPro) so Meta learns who really buys. Use it from a payment link, a CRM or Kaizen.
POST https://connect.nevorai.com/api/v1/contacts/stage
Authorization: Bearer nck_…
Content-Type: application/json
{
"phone": "919876543210",
"stage": "bought",
"amount": 4999, // rupees; REQUIRED for "bought", ignored otherwise
"reason": "Paid by UPI", // optional, shown in the chat
"confidence": 95 // optional, 0-100
}{ "stage": "bought", "changed": true }- The same rules as AI and the team apply: a call can move a customer forward, never over what a person chose sideways or back, and never clears a stage. Then
changedisfalsewith areason(same,pinned,backwards). boughtwithout a positiveamountis400 amount_required; a sale with the wrong amount teaches Meta the wrong thing, so check it.- The customer must exist (
POST /api/v1/contactsfirst):404 not_found. - Errors:
400 invalid_phone,400 invalid_stage,400 invalid_amount,409 busy(changed a moment ago: send again); the key errors are the same as for sends.
Where outcomes go back (Connect button apps)
An app connected with the Connect button registers where lead outcomes should be sent, once, right after the code swap:
POST https://connect.nevorai.com/api/v1/connections/outcome-hook
Authorization: Bearer nck_… // the connection's own key
{ "url": "https://adsproindia.com/api/public/webhooks/status", "key": "<a long random key Connect will send back>" }The url must be https and on the app's own registered domain (its return address's origin). Connect then posts each outcome of a lead the app sent (lead_reference = the id it sent):
POST <url>
Authorization: Bearer <key>
Idempotency-Key: <outcome id>
{ "lead_reference": "…", "status": "qualified", "event_id": "<outcome id>", "source": "whatsapp" }
{ "lead_reference": "…", "status": "purchased", "amount": 4999, "currency": "INR", "event_id": "…", "source": "whatsapp" }status is the app's word (AdsPro: contacted, qualified, not_qualified, booked, no_show, purchased). 2xx = taken; 400/404/422 = not accepted, not retried; 401/403 = the key or connection ended, not retried; anything else is retried (1 min, 5 min, 30 min, 2 h, 6 h, 24 h) and then dropped. Retries send the same event_id, so the receiver should treat a repeat as already done.
Plugins (no code)
For apps that can only send a webhook (Facebook lead ads through Pabbly Connect / Zapier / Make, Google Sheets or Forms, Razorpay, IndiaMART, Elementor, Tally, Typeform…), the owner adds a plugin instead of using a key: Connect → Settings → Plugins → pick the app. Each plugin has its own private link:
POST https://connect.nevorai.com/api/hooks/nch_…- No key header: the link IS the password (⋯ → New link replaces a leaked one). Up to 120 a minute and 3,000 a day per link.
- Anything goes in: JSON (nested is fine), form fields, multipart, or
GET …?phone=…&name=…. Facebook's{name, values}and Tally's{label, value}lists become one field per question. - The first arrival is kept as an example: its fields show on the setup screen, where Connect has already guessed the phone and name fields. The owner checks them, picks a follow-up (or "only save to Contacts"), maps the follow-up's details and event time (a field, or the same for everyone), tags and opt-in, and saves.
- Each lead: the customer is saved and tagged first, then put into the follow-up, whose first message goes at once. The same number from the same plugin twice in one India day gets the messages once. STOP stays off.
- The answer is always JSON:
{"ok": true, "result": "added", "message": "Added to \"Webinar reminders\"."}. Results:added,saved,duplicate,sample(setup not finished),no_phone,missing(a detail or event time),stopped,off,limit,error. A bad link is404; too much data (over 64 KB) is413. - The last 30 arrivals per plugin are kept (Settings → Plugins → Recent). "Test with my number" sends the latest example again with the owner's number.
- Code:
src/lib/plugins.ts(rules, tested),src/lib/plugins-run.ts(the link), migration0013_plugins.sql.
Shared helper for Nevorai-built sites
Copy into the site as lib/connect.ts. Env: CONNECT_API_KEY, and CONNECT_STATUS_SECRET if you use callbacks. Server-only.
import { createHmac, timingSafeEqual } from "node:crypto";
const BASE = process.env.CONNECT_API_URL ?? "https://connect.nevorai.com";
export type ConnectSend = {
to: string;
template: string;
language?: string;
variables?: string[];
header?: { document: string; filename?: string } | { image: string } | { video: string } | { text: string };
buttons?: string[];
ref?: string;
};
export type ConnectResult =
| { ok: true; id: string; status: string; duplicate?: boolean }
| { ok: false; code: string; message: string; retry: boolean };
/** Sends one approved template from the client's number. Never throws. */
export async function sendWhatsApp(msg: ConnectSend): Promise<ConnectResult> {
const key = process.env.CONNECT_API_KEY;
if (!key) return { ok: false, code: "not_configured", message: "CONNECT_API_KEY is not set", retry: false };
try {
const res = await fetch(`${BASE}/api/v1/send`, {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify(msg),
signal: AbortSignal.timeout(15_000),
});
const json = await res.json().catch(() => ({}));
if (res.ok) return { ok: true, id: json.id, status: json.status, duplicate: json.duplicate };
return {
ok: false,
code: json.error?.code ?? `http_${res.status}`,
message: json.error?.message ?? `Connect answered ${res.status}`,
retry: res.status === 429 || res.status >= 500,
};
} catch (e) {
return { ok: false, code: "network", message: e instanceof Error ? e.message : "Connect unreachable", retry: true };
}
}
/** For the status-callback route: true only if Connect signed this body. */
export function isFromConnect(rawBody: string, signature: string | null): boolean {
const secret = process.env.CONNECT_STATUS_SECRET;
if (!secret || !signature) return false;
const want = Buffer.from(`sha256=${createHmac("sha256", secret).update(rawBody).digest("hex")}`);
const got = Buffer.from(signature);
return want.length === got.length && timingSafeEqual(want, got);
}Always pass ref (the order or payment id). Then a retried payment webhook, or a double click, never messages the customer twice.