Integration guide
Let your customers message your business on WhatsApp while your own website does the work: take orders, check bookings, confirm payments and send receipts, without anyone opening WhatsApp by hand. This guide covers everything you need to connect.
A customer messages your WhatsApp number
They just send a word you chose, like “order”. No app, no link.
Mama's Kitchen
online
Customer's WhatsApp
IdentifyOrg
Your website
AI assistant
customer: 2348012345678 message: "order" → keyword rule matched: order → guided session started
order, menu, action Hand over to my website.Billed monthly from your wallet (30-day periods, renewed automatically; stop any time). A message is one customer message we handle, whichever way it's answered.
| Plan | Price / month | Numbers | Messages | Website connection | Images & documents | Train the AI | Reply window |
|---|---|---|---|---|---|---|---|
| Free trial | ₦0 (7 days) | 1 | 100 | Yes | Yes | 5 pages | 24h |
| Starter | ₦10,000 | 1 | 1,000 | No | No | No | 24h |
| Growth | ₦25,000 | 2 | 4,000 | Yes | No | No | 24h |
| Business | ₦60,000 | 5 | 15,000 | Yes | Yes | 25 pages | 48h |
| Scale | ₦120,000 | 10 | 50,000 | Yes | Yes | 100 pages | 72h |
| Enterprise | ₦200,000 | 25 | 150,000 | Yes | Yes | 400 pages | 72h |
Calls to /v1/wa/* use your secret API key in the X-IdentifyOrg-Key header (or a dashboard login). Requests we send to your website are authenticated by the signature described below, so keep the signing secret private.
to field, or phone), use digits only, with the country code, no +, no spaces and no leading 0, like 2348012345678. We also accept +234 801 234 5678 and Nigerian 0801 234 5678 and convert them, but for other countries always include the country code. The numbers in webhooks always arrive in the digits-only form.We send a POST with a JSON body to your website address for each customer message routed to it. Respond with 2xx within 8 seconds.
POST https://yourshop.com/api/whatsapp
Content-Type: application/json
X-IdentifyOrg-Event: message
X-IdentifyOrg-Event-Id: 3EB0C767D0E5A1F2B7
X-IdentifyOrg-Signature: t=1791650000,v1=9f2b…c41a
{
"id": "3EB0C767D0E5A1F2B7",
"type": "message",
"created_at": "2026-10-10T16:40:12.114Z",
"channel_id": "5b1f3c1e-…",
"conversation_id": "a77d90c2-…",
"customer": { "phone": "2348012345678", "name": "Tola" },
"message": { "type": "text", "text": "order jollof x2" },
"session": { "active": true, "keyword": "order" }
}message.type is text, or image, audio, video, document, sticker, location. Only text is passed through as text; for other types text is the caption, or empty. For location (a pin the customer shares) the message also carries location: { lat, lng, name }.id is unique per message: use it to ignore duplicates (we retry failures).type: "test" is sent by the dashboard's test button.The X-IdentifyOrg-Signature header is t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw request body>" using your signing secret. Compute it over the raw body exactly as received (not re-serialised JSON), compare in constant time, and reject timestamps older than five minutes.
import crypto from "node:crypto";
// rawBody must be the exact bytes we sent (before JSON parsing).
export function verifyWhatsApp(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(parts.v1 ?? "");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < toleranceSeconds;
return fresh && a.length === b.length && crypto.timingSafeEqual(a, b);
}import hmac, hashlib, time
def verify_whatsapp(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
signed = f"{parts['t']}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
fresh = abs(time.time() - int(parts["t"])) < tolerance
return fresh and hmac.compare_digest(expected, parts.get("v1", ""))<?php
function verify_whatsapp(string $rawBody, string $header, string $secret, int $tolerance = 300): bool {
parse_str(str_replace(',', '&', $header), $p);
$expected = hash_hmac('sha256', $p['t'] . '.' . $rawBody, $secret);
return abs(time() - (int)$p['t']) < $tolerance && hash_equals($expected, $p['v1'] ?? '');
}func verifyWhatsApp(raw []byte, header, secret string) bool {
parts := map[string]string{}
for _, kv := range strings.Split(header, ",") {
if k, v, ok := strings.Cut(kv, "="); ok { parts[k] = v }
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(parts["t"] + "."))
mac.Write(raw)
ts, _ := strconv.ParseInt(parts["t"], 10, 64)
fresh := math.Abs(float64(time.Now().Unix()-ts)) < 300
return fresh && hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(parts["v1"]))
}You have two ways to answer, and can mix them.
Return JSON from your webhook. reply is one message; replies is up to 6, sent in order. Each can be a string or an object (text, image, document).
HTTP/1.1 200 OK
Content-Type: application/json
{
"replies": [
"Great choice, Tola! Jollof rice x2 is ₦5,000.",
"Reply 1 to pay by transfer, 2 to pay on delivery."
],
"session": "continue"
}| Field | Meaning |
|---|---|
reply / replies | What to send the customer. |
session | "continue" keeps the guided session going, "end" hands the chat back to the assistant, "start" begins a session. |
handoff, reason | true flags the chat for a person and stops automation on it; the owner gets an email. |
Return an empty 200, process the order, then reply when ready. Order updates, payment confirmations and receipts work this way. You can only reply to customers who messaged within your plan's reply window.
curl -X POST https://api.identifyorg.com/v1/wa/channels/CHANNEL_ID/send \
-H "X-IdentifyOrg-Key: YOUR_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "2348012345678",
"text": "Order #1042 is out for delivery. Rider: Emeka, 0803 000 0000."
}'Available on Business and above (and in the trial). Provide a public url we can fetch, or base64 data.
{
"replies": [
{ "type": "text", "text": "Payment received. Thank you! Your receipt is attached." },
{
"type": "document",
"url": "https://yourshop.com/receipts/INV-1042.pdf",
"filename": "receipt-INV-1042.pdf",
"caption": "Receipt INV-1042"
}
],
"session": "end"
}curl -X POST https://api.identifyorg.com/v1/wa/channels/CHANNEL_ID/send \
-H "X-IdentifyOrg-Key: YOUR_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "2348012345678",
"text": "Payment received. Here is your receipt.",
"media": {
"kind": "document",
"url": "https://yourshop.com/receipts/INV-1042.pdf",
"filename": "receipt-INV-1042.pdf"
}
}'caption shows under images and documents.A session is a conversation your website drives step by step: menu, choose, confirm, pay. While it's active, every message from that customer goes to your website, so you don't need to repeat the keyword. Start one with a keyword rule (action “Hand over to my website”), by returning "session": "start", or by sending with "session": "start" in the send API. End it with "session": "end" in a response, or POST /v1/wa/channels/{id}/sessions/end. A session also ends by itself after 30 minutes of silence, and the event always tells you whether one is active (session.active). Keep your own state, keyed by the customer's phone number.
Use your linked number to send one-time codes, or to confirm a person is who they say they are, without waiting for Meta's business approval. Available on Growth and above.
Pass the number and we deliver a fresh code from your number, worded as your brand. Codes are stored only as a one-way hash, expire after the time you set (default 10 minutes), and lock after 5 wrong tries.
# 1. Send the code
curl -X POST https://api.identifyorg.com/v1/wa/channels/CHANNEL_ID/otp \
-H "X-IdentifyOrg-Key: YOUR_SECRET_KEY" -H "Content-Type: application/json" \
-d '{ "to": "2348012345678", "brand": "Acme Pay", "ttl_minutes": 10 }'
# 2. When the person types the code into your app, check it
curl -X POST https://api.identifyorg.com/v1/wa/channels/CHANNEL_ID/otp/verify \
-H "X-IdentifyOrg-Key: YOUR_SECRET_KEY" -H "Content-Type: application/json" \
-d '{ "to": "2348012345678", "code": "483920" }'
# -> { "valid": true }Send a question and the right answer from your own records. The person replies on WhatsApp; we compare it (ignoring case, spaces and punctuation) and tell you the result. The expected answer is stored only as a one-way hash, and the person gets 3 tries by default. If your website is connected, a signed event of type verification is sent to it when the check ends.
# Ask the person a question only they can answer (the answer comes from your own records)
curl -X POST https://api.identifyorg.com/v1/wa/channels/CHANNEL_ID/verify-identity \
-H "X-IdentifyOrg-Key: YOUR_SECRET_KEY" -H "Content-Type: application/json" \
-d '{ "to": "2348012345678", "question": "What is your date of birth? (DD/MM/YYYY)", "expected": "12/03/1990", "reference": "customer_8841" }'
# -> { "id": "...", "status": "pending" }
# The person replies on WhatsApp. Then check the result (or wait for the signed "verification" event on your website):
curl https://api.identifyorg.com/v1/wa/channels/CHANNEL_ID/verify-identity/VERIFICATION_ID -H "X-IdentifyOrg-Key: YOUR_SECRET_KEY"
# -> { "status": "verified" } (or "failed" / "expired" / "pending")Rules are checked in order against the customer's message (case and punctuation ignored). Set them in the dashboard, or with PUT /v1/wa/channels/{id}/rules.
{
"rules": [
{ "words": ["order", "menu", "food"], "match": "starts", "action": "site" },
{ "words": ["hours", "open"], "match": "contains", "action": "reply",
"reply": "Hi {name}, we are open 8am to 9pm, Monday to Saturday." },
{ "words": ["search", "find"], "match": "starts", "action": "guide",
"reply": "What are you looking for, {name}? Type a product, a place or a budget, like 'jollof under 3000'." },
{ "words": ["human", "agent"], "match": "exact", "action": "ai" }
]
}match: exact (the whole message), starts (begins with the word), contains (the word appears).action: reply sends your fixed text; guide sends a message telling the customer what they can reply with or search for, then passes whatever they type next to your website (or the assistant if no website is connected); site starts a guided session with your website straight away; ai lets the assistant answer.{name} in a reply becomes the customer's first name.On Business and above, the assistant can learn from your own website and documents, so it answers with your real prices, policies and details instead of only the short business profile. It looks through everything you taught it, takes the passages most relevant to the customer's question, and answers from those in your tone. Anything not covered still goes to you.
POST /v1/wa/channels/{id}/knowledge/website with { "url": "https://yourshop.com" } reads your public pages (following your links) and learns from each page.POST /v1/wa/channels/{id}/knowledge/file (multipart file): PDF, Word (.docx), text, Markdown or CSV up to 8 MB.POST /v1/wa/channels/{id}/knowledge/text with { "title", "text" }.GET the same path lists what it knows; DELETE /v1/wa/channels/{id}/knowledge/{source_id} makes it forget one.A customer sends “order”, picks items from a numbered menu, confirms, and receives payment details with an invoice PDF, all handled by your server. The keyword rule order, menu starts the session; "session": "end" returns the chat to the assistant afterwards.
import express from "express";
import { verifyWhatsApp } from "./verify.js";
const app = express();
const SECRET = process.env.WA_SECRET; // whsec_... from the "My website" tab
const carts = new Map(); // phone -> { step, items }
// Keep the raw body: the signature is calculated over it.
app.post("/api/whatsapp", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
if (!verifyWhatsApp(raw, req.get("X-IdentifyOrg-Signature") ?? "", SECRET)) return res.sendStatus(401);
const event = JSON.parse(raw);
if (event.type === "test") return res.json({ reply: "Test received ✅" });
const phone = event.customer.phone;
const text = (event.message.text ?? "").trim().toLowerCase();
const cart = carts.get(phone) ?? { step: "menu", items: [] };
if (cart.step === "menu") {
cart.step = "choose";
carts.set(phone, cart);
return res.json({
replies: ["Hi " + (event.customer.name ?? "there") + "! What would you like?\n1. Jollof rice ₦2,500\n2. Fried rice ₦2,800\n3. Chicken ₦1,500"],
session: "continue",
});
}
if (cart.step === "choose" && ["1", "2", "3"].includes(text)) {
cart.items.push(text); cart.step = "confirm"; carts.set(phone, cart);
return res.json({ reply: "Added. Reply YES to place your order, or another number to add more.", session: "continue" });
}
if (cart.step === "confirm" && text === "yes") {
carts.delete(phone);
const invoice = createOrder(phone, cart.items); // your own order logic
return res.json({
replies: [
{ type: "text", text: "Order placed! Pay ₦" + invoice.total + " to 0123456789 (Mama's Kitchen). We'll confirm when it lands." },
{ type: "document", url: invoice.pdfUrl, filename: "invoice.pdf", caption: "Invoice " + invoice.id },
],
session: "end", // hand the chat back to the assistant
});
}
return res.json({ reply: "Sorry, I didn't get that. Reply with a number from the menu.", session: "continue" });
});Later, when payment arrives, confirm it with POST /v1/wa/channels/{id}/send using the phone number you stored. No one needs to touch WhatsApp.
Base URL https://api.identifyorg.com. Send X-IdentifyOrg-Key with your secret key.
| POST | /v1/wa/channels | Add a number. Body: { label, mode: 'ai' | 'site' } |
| GET | /v1/wa/channels | List your numbers with status and counters. |
| GET | /v1/wa/channels/{id} | One number, with live status, the QR code while pairing, profile and signing secret. |
| PATCH | /v1/wa/channels/{id} | Rename, switch the assistant on/off, or set mode ('ai' or 'site'). |
| POST | /v1/wa/channels/{id}/connect | Start linking: poll GET until it returns a QR code to scan. |
| POST | /v1/wa/channels/{id}/disconnect | Unlink the number. |
| DELETE | /v1/wa/channels/{id} | Remove the number and its conversations. |
| PUT | /v1/wa/channels/{id}/profile | Business information the assistant answers from. |
| POST | /v1/wa/channels/{id}/test | Try the assistant without WhatsApp. Body: { message, history } |
| PUT | /v1/wa/channels/{id}/rules | Starter keywords. Body: { rules: [...] } (actions: reply, guide, site, ai). |
| PUT | /v1/wa/channels/{id}/website | Set your website. Body: { url, fallback_message }. Returns endpoint_secret. |
| POST | /v1/wa/channels/{id}/website/rotate-secret | Make a new signing secret. |
| POST | /v1/wa/channels/{id}/website/test | Send a sample signed message to your website and report the result. |
| POST | /v1/wa/channels/{id}/send | Reply to a customer: text, image or document. Body: { to, text?, media?, session? }. to = the customer's number: digits with country code, like 2348012345678. |
| POST | /v1/wa/channels/{id}/otp | Send a one-time code to any WhatsApp number. Body: { to, brand?, length?, ttl_minutes?, reference? }. Growth plan and above. |
| POST | /v1/wa/channels/{id}/otp/verify | Check the code the person typed. Body: { to, code }. Returns { valid, reason }. |
| POST | /v1/wa/channels/{id}/verify-identity | Ask a question over WhatsApp. Body: { to, question, expected, brand?, ttl_minutes?, max_attempts?, reference? }. |
| GET | /v1/wa/channels/{id}/verify-identity/{verification_id} | Status: pending, verified, failed or expired. |
| POST | /v1/wa/channels/{id}/sessions/end | End a guided session. Body: { phone } |
| GET | /v1/wa/channels/{id}/conversations | Conversations. Query: status, q, attention, limit, offset. |
| GET | /v1/wa/conversations/{id} | Messages in one conversation. |
| POST | /v1/wa/conversations/{id}/reply | Answer as the owner (the assistant pauses on that chat). |
| POST | /v1/wa/conversations/{id}/resume-ai | Give the chat back to the assistant. |
| POST | /v1/wa/conversations/{id}/close | Mark a conversation done. |
| GET | /v1/wa/channels/{id}/knowledge | What the assistant has learned, with slots used and available. |
| POST | /v1/wa/channels/{id}/knowledge/website | Learn from your website. Body: { url } |
| POST | /v1/wa/channels/{id}/knowledge/file | Learn from a document (multipart file). |
| POST | /v1/wa/channels/{id}/knowledge/text | Learn from pasted text. Body: { title, text } |
| DELETE | /v1/wa/channels/{id}/knowledge/{source_id} | Forget one source. |
| GET | /v1/wa/channels/{id}/stats | Questions, answers by source, people, hand-overs. Query: days. |
| GET | /v1/wa/plans | The plans and what each includes. |
| GET | /v1/wa/subscription | Your plan, usage this period and renewal date. |
| POST | /v1/wa/subscription | Start the free trial ({ trial: true }) or choose/change a plan ({ tier }). |
| DELETE | /v1/wa/subscription | Stop auto-renewing. |
Errors use the standard shape { "error": { "code", "message", "status" } }.
| 401 | unauthorized | Missing or invalid API key. |
| 402 | insufficient_balance | Wallet too low to start or change a plan. |
| 402 | plan_inactive | Your plan isn't active (ended, or the monthly messages are used up). |
| 403 | plan_feature | The feature isn't in your plan (website connection, images and documents, training the AI). |
| 403 | knowledge_limit | You've used all the training slots on your plan. |
| 403 | channel_limit | You've reached the number of WhatsApp numbers your plan allows. |
| 403 | outside_reply_window | That customer hasn't messaged this number within your plan's reply window. |
| 400 | invalid_endpoint | The website address must be a public https:// address. |
| 400 | invalid_media | Unsupported file type, over 5 MB, or the file couldn't be fetched. |
| 409 | not_connected | The WhatsApp number isn't connected right now. |
| 429 | rate_limited | More than 20 messages in a minute from one number. |
| 503 | whatsapp_link_unavailable | The WhatsApp service is briefly unavailable. Retry. |
Finding this hard to integrate?
Open a ticket and our technical team will help you get it working. We reply by email. Or WhatsApp or call +234 913 995 7618.