Documentation
WhatsApp Direct Connection
How to link a number by QR, send messages, receive events in your system and avoid restrictions from Meta.
Updated 12 November 2026
Contents
Overview
Direct Connection links an ordinary WhatsApp number (personal or WhatsApp Business) to our API by scanning a QR code, just like linking WhatsApp Web. From then on you send messages and receive what your customers write, from n8n, your CRM or your own code.
What you get:
an access key to send messages and check the status of your numbers;
a webhook: every message received and every status change reaches the address you choose, signed;
the customer portal, where you link the number and follow your usage.
All requests go to:
https://wa.automate-ai.digitalKnow the risk before you start. Direct Connection is not Meta's official channel. Meta may restrict a number it considers automated, and that is outside our control. Follow the good practices and, for high volumes or campaigns, use the Official API.
Getting started
1. Link the number
In the portal, under My products, click Link this number. A QR code appears. On your phone, open WhatsApp, go to Linked devices → Link a device and scan the code.
The code stays active for 5 minutes. If it expires, click Link again. The number shows as connected as soon as the scan completes.
The phone does not need to stay online: the connection works as one more device on your account.
2. Set up the webhook and the key
In the portal, under Technical integration:
Webhook: enter the address that will receive events. The portal shows the webhook secret once; it is what verifies the signature. Keep it.
Access key: generate the key. It is also shown only once.
Authentication
Every request carries the key in the x-api-key header:
curl https://wa.automate-ai.digital/instances/NUMBER_ID \
-H "x-api-key: wak_xxxxxxxx_xxxxxxxx"Every response has the same shape:
{ "ok": true, "data": { "...": "..." } }{ "ok": false, "error": { "code": "INSTANCE_NOT_FOUND", "message": "…", "retryable": false } }The NUMBER_ID (instanceId) comes in every event your webhook receives.
A new key invalidates the previous one the moment it is generated. Anything using the old key stops working until it is updated.
Sending messages
curl -X POST https://wa.automate-ai.digital/instances/NUMBER_ID/messages \
-H "x-api-key: $KEY" -H "content-type: application/json" \
-d '{
"to": { "kind": "contact", "id": "351912345678" },
"content": { "type": "text", "body": "Hi! Your order is on its way." },
"idempotencyKey": "order-1234-confirmation"
}'to.idis the number in international format, without+or spaces. For a group:{ "kind": "group", "id": "GROUP_ID" }.idempotencyKey(optional, recommended): if the same request is repeated with the same key, the message is not sent twice.replyTo(optional): theproviderMessageIdof the message you are replying to, so it shows as a quote.
The response is 202 with the message id:
{ "ok": true, "data": { "messageId": "44444444-…", "queuedAt": "2026-11-12T10:00:00.000Z" } }202 means the message entered the queue, not that it went out. The outcome arrives later on the webhook, as message.status.
Messages go out in the order they come in, with a small random gap between them. That is on purpose: bursts are what leads Meta to restrict numbers.
Content types
| Fields |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
media is { "kind": "url", "url": "https://…" }, or { "kind": "base64", "data": "…", "mimeType": "image/png" }.
Buttons do not exist on this channel. A request with "type": "buttons" fails with CAPABILITY_UNAVAILABLE. The API does not turn them into a numbered list, because the customer's answer would arrive as free text and a flow waiting for the tap would wait forever. For buttons, use the Official API.
Nor do templates. With Direct Connection you can write to anyone at any time, with no templates and no 24-hour window. Always use /messages.
Groups
The number can send and receive messages in groups it belongs to. So that your flow is not flooded with every group conversation, only messages from the groups you authorise reach the webhook.
List the number's groups:
curl https://wa.automate-ai.digital/instances/NUMBER_ID/groups -H "x-api-key: $KEY"{ "ok": true, "data": [{ "id": "120363012345678901", "name": "Sales team" }] }Authorise the ones you want to receive. The list replaces the previous one, and takes effect immediately:
curl -X PATCH https://wa.automate-ai.digital/instances/NUMBER_ID \
-H "x-api-key: $KEY" -H "content-type: application/json" \
-d '{ "allowedGroups": ["120363012345678901"] }'Group messages arrive with via, the group, and from, who wrote.
Events
Each event reaches your webhook as a POST with a JSON body. This channel has three types:
| When |
|---|---|
| someone wrote |
| a send changed status: |
| the number's status changed |
Ignore the types you do not know, answering 2xx. The list will grow, and a flow that assumes "if it is not A, it is B" treats a new type as something it is not.
message.received
{
"type": "message.received",
"eventId": "0f0f6ab7-2a2f-4f0b-9b6a-9a5f7d5d31c2",
"instanceId": "0d9b1b6f-6a2a-4a4c-9f66-3f6a3f0b6a2a",
"provider": "baileys",
"occurredAt": "2026-11-12T18:41:07.000Z",
"providerMessageId": "3EB0C767D26B8A1F",
"from": "351912345678",
"pushName": "Maria",
"content": { "type": "text", "body": "Hi, I'd like to know about my order" },
"windowExpiresAt": null
}content types:
| Fields |
|---|---|
|
|
|
|
|
|
|
|
|
|
The media storageUrl is temporary: use it to download the file right away, not to store it.
If your number has message grouping turned on, consecutive texts from the same customer arrive as one event, joined by line breaks. That event carries every id in providerMessageIds.
message.status
On this channel you get sent (the message went out) and failed (it did not: failureCode says why). Each carries the messageId the API returned when you sent. "Delivered" and "read" are not sent on this channel.
connection.updated
Carries status: connecting, qr_required, connected, disconnected or logged_out.
disconnected: the connection dropped. The API reconnects on its own.logged_outwithrequiresReauth: true: the number must be linked again in the portal, with a new QR. This happens, for example, when someone ends the session under Linked devices on the phone.
Verifying the signature
Every webhook request carries:
x-timestamp: 1794519022
x-signature: sha256=9c1f…The signature is an HMAC-SHA256, with the webhook secret, over x-timestamp + "." + raw body. To verify it:
reject the request if
x-signatureorx-timestampis missing;reject it if
x-timestampis more than 5 minutes away from your clock;compute the HMAC over the raw body, byte for byte. If you re-serialise the JSON, the signature fails for some events;
compare using a constant-time comparison.
const crypto = require("crypto");
function validSignature(secret, timestamp, rawBody, signature, nextSignature) {
if (!signature || !timestamp) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = Buffer.from(
"sha256=" + crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex")
);
const matches = (h) => !!h && Buffer.from(h).length === expected.length && crypto.timingSafeEqual(Buffer.from(h), expected);
return matches(signature) || matches(nextSignature);
}Verify before you answer. If the signature fails, answer 401: the event is kept on our side and is not lost. If you answer 2xx before verifying, an event rejected afterwards is lost without a trace.
Changing the secret without losing events. During a change, every request also carries x-signature-next, signed with the new secret. Accept either signature. That way you can switch to the new secret whenever you like, with no window in which events fail.
Errors
In the request's response. These are fixed in the request itself:
HTTP |
| When |
|---|---|---|
400 |
| the body is not right (the message says what) |
401 |
| key missing, wrong or revoked |
404 |
| the number does not exist in your account |
409 |
| only on |
On the webhook, in a message.status of failed. What depends on WhatsApp arrives here, after the 202:
| When |
|---|---|
| the number is not connected. The queue tries again before giving up |
| something this channel does not do (for example, buttons) |
| WhatsApp refused or did not confirm the send. The reason is in |
Event delivery
Answer 2xx as soon as you receive the event, before processing it. Each attempt has 10 seconds. An endpoint that only answers at the end of the flow risks receiving the same event again.
Deduplicate by
eventId. Each event is delivered at least once and may arrive more than once.Order by
occurredAt, not by arrival order.Retries: on a 5xx, a 429 or no answer, we retry up to 8 times over about 30 minutes. A 4xx (except 429) is not retried: the event is kept on our side.
Good practices
What Meta treats as abusive automation, and what leads it to restrict numbers:
messages to people who did not ask for them, especially in quantity and with the same text;
many messages in a row to contacts who never wrote to the number;
new numbers that start sending in volume straight away.
What helps:
reply to people who write to you, and send notices to customers who have already talked to you;
vary the text and avoid shortened links;
start slowly with a new number;
for campaigns, use the Official API with approved templates.
Usage and limits
Each number includes 3,000 sent messages per billing cycle. Received messages do not count.
Beyond that, you buy blocks of 1,000 messages in the portal.
Each number's usage is in the portal, under My products.
A note on senders. WhatsApp is starting to identify some conversations by an internal code instead of the phone number. When the sender's phone is not known, the message is stored but not sent to the webhook: the from field must be a number you can reply to, and that code is not.
