Integrations
Custom and headless stores
Calling the API directly from your own backend, with a worked example.
Calling the API yourself is a first-class option, not a fallback. The WooCommerce plugin and the Shopify app hold no privileged access, they authenticate with an API key and post JSON, exactly as your own backend will. If your store runs on a custom stack, a headless front end, or a cart nobody has written an integration for, this is the same road with less configuration in the way.
Getting ready
Create an API key
Dashboard → API Keys → Generate New Key. The secret is shown once. Store it as an environment variable on your server, never in front-end code or a repository.Link and register a number
See Connect your WhatsApp number. Confirm it withGET /v1/phones; an account with one number can omitphoneNumberIdfrom every send.Submit your templates
POST /v1/notifications/templates/submitwith the event keys you need, then pollGET /v1/notifications/events?refresh=trueuntil they read approved. Do not poll the send endpoint. A 409 is not a transient error.

You send events, not messages
Your backend never composes a WhatsApp message. It declares that something happened, order_confirmation, order_shipped, and supplies named variables. The platform picks the approved template, orders the parameters into their {{n}} placeholders, and handles the 24-hour window for you. That is why an order notification reaches a customer who has never messaged you, and a free-form message does not.
Base URL https://whatsapp-api.growcord.in/api, header Authorization: Bearer sk_live_…. All 18 events and their exact variables are on Event catalog; the full request and response reference is on Notifications API. This page does not repeat them. It shows how to call one correctly from a real order handler.
Worked example: an order was placed
Four things have to be right, and only the first is obvious.
- Never block the order
- Send from a background job, not from the request that takes the payment. An order transition must never wait on an HTTP call to a third party, a slow response there turns a fast checkout into a slow one, and a failed one can lose an order whose payment already succeeded.
- Record the attempt before the retry loop
- One message per (event, order). A 502 followed by a successful retry can still deliver twice, because Meta may have accepted the first attempt after your client gave up waiting.
- Classify the failure before retrying
- Most 4xx responses mean the request is wrong or the account is not configured. Retrying sends the same wrong request and spends the same money.
- Send every variable
- And check missingVariables on the way out even when the call succeeded.
const API = "https://whatsapp-api.growcord.in/api";
/**
* Statuses worth trying again. Everything else is a bad request or a
* configuration problem, and a retry sends the same bad request.
* 429 is conditional — see isSendingCap below.
*/
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
/** `error` is a string on most failures and a zod object on validation ones. */
function readError(body) {
if (typeof body?.error === "string") return body.error;
const formErrors = body?.error?.formErrors ?? [];
const fieldErrors = body?.error?.fieldErrors ?? {};
const parts = [
...formErrors,
...Object.entries(fieldErrors).map(([field, errs]) => field + ": " + errs.join(", ")),
];
return parts.join("; ") || "Unknown error";
}
/**
* A 429 is two unrelated controls with opposite handling.
* The key rate limit clears inside 60 seconds. An account sending cap resets on
* the plan's own cycle, and is told apart by limit/usage/limits in the body.
*/
function isSendingCap(body) {
return body?.limit !== undefined || body?.limits !== undefined;
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function post(path, payload) {
const res = await fetch(API + path, {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.WA_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(20_000),
});
return { status: res.status, headers: res.headers, body: await res.json() };
}
/**
* Called from a background job, never from the checkout request.
* `claim` must return false if this (event, order) was already attempted.
*/
export async function sendOrderConfirmation(order, { claim }) {
if (!(await claim("order_confirmation", order.id))) return { skipped: "duplicate" };
const payload = {
to: order.customerPhone, // E.164, e.g. +919876543210
event: "order_confirmation",
variables: {
customerName: order.customerFirstName,
orderId: order.number, // "#ORD-9001"
orderTotal: order.totalFormatted, // "1,499.00", the template prints the currency
trackingLink: "https://yourstore.com/orders/" + order.token,
},
};
const delays = [1000, 2000, 4000, 8000, 16000];
for (let attempt = 0; attempt <= delays.length; attempt++) {
let result;
try {
result = await post("/v1/notifications/send", payload);
} catch (transport) {
// Timeout, DNS, reset. The send may or may not have happened, which is
// what the claim above is for.
if (attempt === delays.length) throw transport;
await sleep(delays[attempt] * (0.5 + Math.random()));
continue;
}
const { status, headers, body } = result;
if (status < 400) {
if (body.missingVariables?.length) {
// Delivered with a "-" where a value should have been.
console.error("order_confirmation sent with gaps", order.id, body.missingVariables);
}
return { messageId: body.messageId, creditsLeft: headers.get("X-Credits-Remaining") };
}
const message = "[" + status + " " + (body.code ?? "") + "] " + readError(body);
if (status === 429 && isSendingCap(body)) {
// A daily or monthly cap, or a paused account. Backing off cannot clear it.
throw new Error("Sending cap reached (" + body.limit + "). " + message);
}
if (!RETRYABLE.has(status) || attempt === delays.length) {
throw new Error(message);
}
const reset = Number(headers.get("RateLimit-Reset"));
const wait = status === 429 && reset > 0 ? reset * 1000 : delays[attempt] * (0.5 + Math.random());
await sleep(wait);
}
}The retry policy
Retrying the wrong status is how a store spends its credits twice and still does not deliver. This is the whole rule:
- 400, 401, 403, 404
- Never retry. The request is wrong, the key is wrong, or the id does not exist on this account. The same call will fail the same way.
- 409 TEMPLATE_NOT_APPROVED
- Never retry the send. Poll
GET /v1/notifications/events?refresh=trueuntil the template is approved, then send once. - 402 insufficient credits
- Retry slowly, a top-up may land, but back off in minutes rather than seconds and alert someone. This needs a human.
- 429 without limit / usage / limits
- The per-key rate limit. Honour
RateLimit-Resetand retry; it clears inside 60 seconds. - 429 with limit / usage / limits
- An account sending cap, or a paused account. Do not retry, nothing you do clears a daily or monthly cap. Stop, alert someone, resume next cycle.
- 500, 502, 503, 504
- Retry with exponential back-off: 1s, 2s, 4s, 8s, 16s with jitter, five attempts at most. On a
502the charge was already refunded, so each retry costs a fresh credit, cap the attempts. - Transport errors
- Timeout, DNS, connection reset. Retry, but assume the message may have gone out. This is what your own deduplication key is for.
Every status and code is on Errors and retries.
Beyond a single send
- Many recipients at once
POST /v1/notifications/send/batchtakes one event and up to 500 recipients. Each is billed and limit-checked individually. Use it for back-in-stock fan-outs rather than 500 separate calls.- Delivery status
GET /v1/notifications/status/:messageId, using themessageIdthe send returned.- Replies
- Set an inbound webhook with
PATCH /v1/phones/:phoneNumberId/webhook. See Receiving messages, and note the payload carries no signature header, so the callback URL is itself the secret. - Free-form replies
POST /v1/messages/sendhandles text, media, location and interactive messages, but only inside the customer's 24-hour window. Outside it, a template is the only thing that will be delivered. See The 24-hour window.
What a send costs
Cost is per conversation category, not one flat rate per message. A marketing message costs materially more than a utility one, and a service reply inside an open window costs nothing. If you are budgeting a campaign from a utility-message price you will be wrong by a wide margin, see Credits and billing.
Before you go live
- The key is in an environment variable on the server, and not in any client bundle.
- Sends happen in a background job, and the checkout path does not wait on them.
- Every (event, order) pair is claimed before the first attempt, not after the last.
- Non-retryable statuses raise rather than loop, and a
429is classified before backing off. missingVariablesis logged as an error, because those messages went out with gaps.- You have sent one real message to your own number, end to end.
