Send WhatsApp from your own software, through one key.
OneKey sits between your system and WhatsApp. You talk to a small JSON API over HTTPS; OneKey takes care of the number, the approved templates, the 24-hour rule, consent and delivery — and tells you what happened by webhook. Three ways in, depending on what you are building.
Paste one script tag for an assistant that answers parents on your site, and a "Chat on WhatsApp" button that lands in the right inbox.
publishable key · no server needed ERP & CRM Notices, OTPs, reminders from your backendServer-to-server: sync the roll, send templates in bulk, reply inside the window, receive delivery and reply webhooks.
secret key · webhooks CMS WordPress, Drupal, any site builderThe widget and the button go in the theme; a notice you publish can go out as a broadcast from a small server hook.
script tag · optional batch sendReplace the host with the one you were given for production.
A secret key stays on your server. A publishable key (tvp_…) may sit in a web page and can only chat.
Phone numbers in E.164 (+919876543210); a bare 10-digit Indian number is accepted and normalised.
A school issues its own keys from its console; a platform or partner gets one from Techvein. Shown once — only a hash is kept.
Quick start — a real message in five minutes
- Get a key with the right scopes. For sending you need
messages:send; addmessages:readto poll status andwebhooks:manageto be told instead of polling. Ask for the smallest set that does the job —adminis for Techvein's own tooling. - Pick an approved template. Outside a 24-hour window WhatsApp only allows templates Meta has approved. List what is ready on your number:
Each template lists itscurl https://whatsapp.techvein.in/v1/templates \ -H "Authorization: Bearer tvk_live_…"variablesand the languages it is approved in. - Send one.
The answer iscurl -X POST https://whatsapp.techvein.in/v1/messages \ -H "Authorization: Bearer tvk_live_…" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: fee-2026-09-4410" \ -d '{ "to": "+919876543210", "template": "fee_due_reminder", "variables": { "parent_name": "Mrs. Sharma", "student_name": "Aarav", "amount": "12,400", "due_date": "12 Sep 2026" } }'202— accepted, not yet delivered:
Everything that can fail fast — no approved template on this number, an opted-out contact, a closed window, a blown quota — fails here, synchronously, with a named error (see Errors). Send the same request twice with the same{ "id": "msg_01J…", "status": "queued", "sender": { "number": "+919065513341", "display_name": "TatvaOS" }, "template": { "name": "fee_due_reminder", "language": "en", "category": "utility" }, "conversation": { "id": "cnv_01J…", "window_expires_at": null, "billable": true }, "scheduled_for": null, "held_for": null }Idempotency-Keyand you get the same message back, not a second one. - Learn what happened. Either poll
GET /v1/messages/msg_01J…, or register a webhook once and receive every change:
The response carries the signingcurl -X POST https://whatsapp.techvein.in/v1/webhooks \ -H "Authorization: Bearer tvk_live_…" -H "Content-Type: application/json" \ -d '{ "url": "https://erp.example.in/hooks/onekey", "events": ["message.status", "message.received"] }'secret— shown once. Statuses arrive in order:submitted → sent → delivered → read, orfailedwith a reason.
held_for: "quiet_hours", scheduled_for). An OTP or an emergency passes straight through with "options": { "respect_quiet_hours": false } or "priority": "emergency".Website — chat on the page, or on WhatsApp
Two doors for a school's own site, both safe to put in public HTML because they use a publishable key: it is hard-locked to bot:chat, so it can talk to the assistant and nothing else — never send a message, never read the roll, never see a conversation.
1 · The chat widget
Copy the tag from the school console (Integrations → Website widget), or write it by hand:
<script src="https://whatsapp.techvein.in/widget/onekey-widget.js"
data-key="tvp_live_…"
data-title="St. Mary's School, Kanpur"
data-greeting="Ask me about fees, timetable or holidays."
defer></script>
The widget calls POST /v1/bot/messages with the visitor's text and a session id; the assistant answers from the school's knowledge documents and, for a verified parent, from the roll. It is rate-limited per IP. When a visitor asks for a person or something out of scope, a ticket is raised into the school's Inbox; an admission enquiry becomes a structured Request the office answers from the console.
Student data (fees, attendance) needs a verified parent. On the web that is a one-time code sent to the parent's WhatsApp — the widget drives it, but the calls are public if you build your own UI:
POST /v1/identity/challenge { "phone": "+919876543210" } # OneKey sends the identity_otp template
POST /v1/identity/verify { "phone": "+919876543210", "code": "482913" }
# → { "verified": true, "contact_id": "ctc_…", "identity_level": "L2" }
2 · The "Chat on WhatsApp" button or QR code
A link that opens WhatsApp with the school's code already typed, so OneKey knows which school the very first message is for — even on a number several schools share:
curl https://whatsapp.techvein.in/v1/deeplink -H "Authorization: Bearer tvk_live_…"
# → { "data": [ { "sender": "+919065513341",
# "url": "https://wa.me/919065513341?text=Hi%20%23SMK4821",
# "qr": "https://…/create-qr-code/?data=…" } ] }
<a href="https://wa.me/919065513341?text=Hi%20%23SMK4821">Chat with us on WhatsApp</a>
The visitor sends "Hi #SMK4821"; OneKey routes it to St. Mary's, opens a 24-hour window, and the assistant answers or a person replies from the Inbox. Print the QR on the notice board and the prospectus for the same effect.
ERP & CRM — from your backend
A server-side integration holds a secret key and does four things: keeps the roll in sync, sends, replies inside the window, and listens for webhooks. A platform that serves many schools (an ERP) holds one key and names the school on each call with on_behalf_of; each school has granted it that right (a delegation) from the console.
Keep the roll in sync
Push parents as they change — nightly, or on every edit. Records merge by external_ref, then by phone; a field you do not send is left alone; a parent who was erased under DPDP stays erased.
curl -X POST https://whatsapp.techvein.in/v1/contacts/sync \
-H "Authorization: Bearer tvk_live_…" -H "Content-Type: application/json" \
-H "X-On-Behalf-Of: SMK4821" \
-d '{ "records": [
{ "external_ref": "TAT-PAR-4410", "phone": "+919876543210", "name": "Mrs. Sharma",
"language": "hi",
"attributes": { "student_name": "Aarav", "class": "5", "section": "A",
"fee_due_date": "2026-09-12", "fee_amount": "12,400", "fee_status": "unpaid" } }
] }'
on_behalf_of works as a body field, a query parameter, or the X-On-Behalf-Of header. Attributes are what automations and group rules filter on, so the roll's own fields decide who gets a fee reminder.
Send to many
One template, up to 10,000 recipients, each with its own variables. Every recipient becomes its own message with its own status.
curl -X POST https://whatsapp.techvein.in/v1/messages/batch \
-H "Authorization: Bearer tvk_live_…" -H "Content-Type: application/json" \
-d '{
"on_behalf_of": "SMK4821",
"template": "holiday_notice",
"recipients": [
{ "to": "+919876543210", "variables": { "reason": "Diwali", "date": "20 Oct 2026" } },
{ "to": "+919876543211", "variables": { "reason": "Diwali", "date": "20 Oct 2026" } }
],
"options": { "priority": "normal" }
}'
Reply inside the window, send media
When a parent has written in (you will know from the message.received webhook), you have 24 hours in which free-form text and media are allowed — and free:
POST /v1/conversations/cnv_01J…/reply
{ "text": { "body": "Your receipt is attached." } }
POST /v1/messages
{ "to": "+919876543210",
"media": { "url": "https://erp.example.in/receipts/482913.pdf",
"caption": "Fee receipt", "filename": "receipt-482913.pdf" } }
The media URL must be reachable from the internet — Meta fetches it. Outside the window a media send is refused with outside_session_window; use a template that carries a media header instead.
Read back
GET /v1/messages?limit=50 # recent sends with status
GET /v1/messages/msg_01J… # one message
GET /v1/conversations # threads, newest first, with window_expires_at
GET /v1/conversations/cnv_01J… # the thread, both directions
GET /v1/media/med_01J… # a file a parent sent (OneKey keeps a copy)
GET /v1/usage # this month's counts and cost
CMS — WordPress, Drupal, or any site builder
A content site has no backend of its own to speak of, so the integration is mostly the website one, placed in the theme rather than in one page:
- Put the widget tag in the theme's footer (WordPress: Appearance → Theme File Editor →
footer.php, or any "custom scripts" box a plugin gives you). It then appears on every page. The key is publishable, so it is fine in public HTML. - Add the "Chat on WhatsApp" link to the header, the contact page and the admissions page — the
wa.meURL fromGET /v1/deeplink. It works on the phone as a tap and on the desktop as a QR. - Optional — a notice you publish becomes a WhatsApp broadcast. A small server hook (a WordPress
publish_postaction, a Drupal rule, a Zapier step) callsPOST /v1/messages/batchwith an approved template such asholiday_noticeand the recipients fromGET /v1/contacts. This part needs a secret key, so it runs on a server, never in the theme.
tvk_ key in a page, a theme, or a client-side script. If one leaks, revoke it from the console (Integrations → Keys) and issue another; nothing using the old one will work from that moment.Webhooks — how you find out what happened
Register once with POST /v1/webhooks; an empty events list subscribes to everything. Each delivery is a POST to your URL with a JSON body and three headers:
X-Techvein-Event-Type: message.status
X-Techvein-Event-Id: evt_01J…
X-Techvein-Signature: t=1757060000,v1=8f3c… # HMAC-SHA256 of "<t>.<raw body>" with your secret
// Node — verify before you trust the body
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, rawBody, header) {
const p = Object.fromEntries(header.split(",").map(s => s.split("=")));
if (Math.abs(Date.now() / 1000 - Number(p.t)) > 300) return false;
const mac = createHmac("sha256", secret)
.update(`${p.t}.${rawBody}`).digest("hex");
return timingSafeEqual(Buffer.from(mac), Buffer.from(p.v1));
}
# Python
import hmac, hashlib, time
def verify(secret: str, raw_body: bytes, header: str) -> bool:
p = dict(kv.split("=") for kv in header.split(","))
if abs(time.time() - int(p["t"])) > 300:
return False
mac = hmac.new(secret.encode(), f"{p['t']}.".encode() + raw_body,
hashlib.sha256).hexdigest()
return hmac.compare_digest(mac, p["v1"])
Answer 2xx quickly and do the work afterwards; a non-2xx answer is retried on a published back-off schedule, and the same event_id can therefore arrive twice — make handling idempotent.
| Event | When | Body (after the common event, tenant_id, tenant_code, event_id, occurred_at) |
|---|---|---|
message.status | Every change to a message you sent | message: { id, status, to, failure_code, failure_reason }, plus the context you sent with it |
message.received | A parent wrote in | conversation: { id, window_expires_at }, contact: { id, e164, name, identity_level, external_ref }, message: { id, type, text, media, sender }, routing: { resolved_via, handled_by } |
conversation.opened · conversation.escalated | A new thread; a thread handed to a person | The conversation and contact |
request.created · request.decided | A parent filed a leave / transport / admission request; the office answered | The request, its fields in the order asked, the decision |
template.approved · template.rejected · template.status_changed | The template you proposed moved | The template, its state, a note on rejection |
contact.opted_out · ticket.created · invoice.issued | A STOP; a ticket for a person; the monthly statement | The contact / ticket / invoice |
Templates — getting one approved
WhatsApp allows a business to start a conversation only with a template Meta has approved, and approval is per number and per language. OneKey tracks that pairing as a binding; nothing sends until a binding is approved. Propose a template from your system, or from the school console's composer:
curl -X POST https://whatsapp.techvein.in/v1/templates \
-H "Authorization: Bearer tvk_live_…" -H "Content-Type: application/json" \
-d '{ "name": "exam_hall_ticket", "category": "utility",
"body": "Dear {{parent_name}}, the hall ticket for {{student_name}} is ready for collection from {{date}}.",
"variables": ["parent_name", "student_name", "date"] }'
# → 201 { …, "state": "in_review", "status": "awaiting_binding",
# "next": "Techvein reviews the template, submits it to Meta for this tenant's numbers, and marks it approved when Meta clears it." }
| State | Meaning | Who moves it on |
|---|---|---|
in_review | Techvein has it | A Techvein operator reads it and submits it to Meta for approval |
submitted | It is at Meta, with a pending binding per number and language | Meta — minutes to a few days |
approved | It can be sent | You receive template.approved |
rejected | With the reason in a note | You receive template.rejected; fix and propose again |
Rules that save a round trip: names are lowercase with underscores; a body may not start or end with a variable; authentication templates (OTPs) have a fixed shape Meta dictates; marketing needs an explicit opt-in from every recipient, utility and authentication are covered by enrolment. An approved template cannot be edited — propose a new one.
Errors — every refusal names itself
Every error is JSON with a stable code, a sentence for a person, and where it helps, a remedy and details:
{ "error": "outside_session_window",
"message": "The 24-hour session with +919876543210 closed at 2026-09-04T11:02:00Z.",
"remedy": "Send an approved template to reopen the conversation.",
"details": { "suggested_templates": ["fee_due_reminder", "holiday_notice"] } }
| HTTP | Code | It means |
|---|---|---|
| 400 | invalid_request · invalid_phone | The body is wrong; the message names the field |
| 401 | unauthorized | Missing or unknown key |
| 403 | insufficient_scope · no_delegation · ip_not_allowed | The key lacks the scope; the school has not delegated to your platform; the key is pinned to other IPs |
| 409 | no_sender_available | The tenant has no active WhatsApp number attached — ask Techvein |
| 409 | template_not_approved | No approved binding for that template on this number and language |
| 409 | outside_session_window | Text or media outside the 24-hour window — send a template |
| 409 | contact_opted_out | The parent sent STOP for this category |
| 409 | idempotency_conflict · idempotency_in_flight | Same Idempotency-Key, different body; or the first request is still being processed |
| 429 | quota_exceeded · rate_limited | Daily quota, or too many requests; retry_after_seconds says how long |
| 502 | provider_error | WhatsApp refused or timed out; the message stays failed with the reason |
Scopes
| Scope | Lets a key… |
|---|---|
messages:send | Send templates and replies to parents |
messages:read | Read delivery status and history |
conversations:read · conversations:write | Read threads and the inbox · reply, assign, hand back to the assistant |
templates:read · templates:write | List approved templates · draft and propose templates |
contacts:read · contacts:write | Read the roll and consent · sync the roll, record consent |
numbers:read | See which number you send from; get deep links |
webhooks:manage | Register and remove webhooks |
bot:chat · bot:configure | Talk to the assistant (what the widget uses) · change what it knows and how it behaves |
admin | Everything, bypassing the rest of this list — reserved for Techvein's own tooling |
All endpoints
Read live from this server's OpenAPI document, so it is never out of date with the code that is running.