WhatsApp (QR code)

Connect a WhatsApp number by scanning a QR code, like WhatsApp Web, then send and receive messages

The whatsapp-web integration pairs a regular WhatsApp or WhatsApp Business app number by QR code, the same way WhatsApp Web does. It needs no Meta business account and no message templates.

This integration uses the unofficial WhatsApp Web protocol. WhatsApp may restrict or ban numbers that send automated or unsolicited messages. Use it with numbers whose owners consent, and prefer WhatsApp Business (the official Cloud API) where its rules fit your use case.

How it works

Tenklo runs a WhatsApp gateway next to the server. The gateway holds one WhatsApp Web session per connection, encrypted at rest in Tenklo's database, and keeps it online.

  1. Your user opens the Connect UI and picks the integration.
  2. The Connect UI shows a QR code, refreshed by WhatsApp every 20 seconds or so.
  3. Your user scans it from WhatsApp on their phone: Settings → Linked devices → Link a device.
  4. The connection is created, with the paired number in its credentials (session_id, phone, name).

You then send messages with the /whatsapp API and receive messages and delivery updates as webhooks.

Deleting the connection logs the device out of the number. A session left without a connection (a QR code scanned after its pairing expired, or the connections of a deleted integration) is logged out the next time it reports anything.

Quickstart

Create the integration

In Tenklo, go to Integrations -> Configure New Integration -> WhatsApp (QR code). There is nothing to configure.

Pair a number

Go to Connections -> Add Test Connection -> Authorize, and scan the QR code with the phone of the number to connect.

Send a message

curl -X POST https://api.tenklo.dev/whatsapp/messages \
  -H 'Authorization: Bearer <ENVIRONMENT-API-KEY>' \
  -H 'Connection-Id: <CONNECTION-ID>' \
  -H 'Provider-Config-Key: <INTEGRATION-ID>' \
  -H 'Content-Type: application/json' \
  -d '{ "to": { "kind": "contact", "externalId": "5511999990000" }, "content": { "kind": "text", "text": "Hello" } }'

API

Every endpoint takes an Environment API key (Authorization: Bearer <ENVIRONMENT-API-KEY>, with the environment:whatsapp scope for a scoped key) and the connection in the Connection-Id and Provider-Config-Key headers.

The bodies and webhooks use the shapes of the StackChat channel contract (camelCase), so a payload built for its message.send command can be sent as is.

EndpointDescription
POST /whatsapp/messagesSends a message. Answers 202 with { messageId, commandId }; the outcome arrives in message.status webhooks.
POST /whatsapp/mediaUploads a file to send: the raw body, with its Content-Type, and an optional ?file_name=. Answers the media reference for the message.
GET /whatsapp/media?object_key=Downloads a file of the connection: one it received, or one uploaded for it.
POST /whatsapp/groups/syncAsks for the groups of the number. They arrive in a groups.synced webhook.
GET /whatsapp/statusThe last status of the number: status, reason, account, sendRestriction, updatedAt.
POST /whatsapp/sessions/importCreates a connection from a session paired elsewhere, without a new QR code. Takes only the Provider-Config-Key header.

Import a paired session

A number already paired with another Baileys-based service moves without scanning a new QR code, provided that service stores its session the way Tenklo does: one object per key, creds and keys/<type>/<encodeURIComponent(id)>, each the JSON written with Baileys' BufferJSON.replacer (StackChat's worker-wpp-lib does).

Stop the previous service's session first: two devices on the same session log each other out. Then send its decrypted objects:

{
    "connection_id": "optional-id",
    "tags": { "tenant_id": "..." },
    "objects": [
        { "path": "creds", "json": "{\"noiseKey\":...}" },
        { "path": "keys/session/5511999990000.0", "json": "{...}" }
    ]
}

The reply has the new connection (connection_id, session_id, phone, name), and the number connects right away. An import never overwrites a connection or a session: an existing connection_id answers 409.

Send a message

{
    "messageId": "0d7a6c1e-5b8f-4a2d-9c3e-1f2a3b4c5d6e",
    "to": { "kind": "contact", "externalId": "5511999990000" },
    "content": { "kind": "text", "text": "Hello" }
}
  • messageId is optional (a UUID, generated when missing) and comes back in the message.status webhooks.
  • to.kind is contact or group. For a contact, externalId is the phone number with its country code (digits only) or a JID; for a group, the group JID ([email protected]) from groups.synced.
  • content.kind is text (with text, up to 65,536 characters) or image, video, audio, document (with media and an optional caption of up to 1,024 characters).

To send a file, upload it first and put the reply in content.media:

curl -X POST 'https://api.tenklo.dev/whatsapp/media?file_name=invoice.pdf' \
  -H 'Authorization: Bearer <ENVIRONMENT-API-KEY>' \
  -H 'Connection-Id: <CONNECTION-ID>' \
  -H 'Provider-Config-Key: <INTEGRATION-ID>' \
  -H 'Content-Type: application/pdf' \
  --data-binary @invoice.pdf
# {"objectKey":"tenants/.../media/.../...","mimeType":"application/pdf","sizeBytes":48213,"fileName":"invoice.pdf"}
{
    "to": { "kind": "contact", "externalId": "5511999990000" },
    "content": { "kind": "document", "media": { "objectKey": "tenants/.../media/.../...", "mimeType": "application/pdf", "fileName": "invoice.pdf" } }
}

Messages leave one at a time per number, with a random pause between them and "typing..." before messages to contacts, to look less automated.

Webhooks

Configure a webhook URL in Environment Settings. Each event of the number arrives as a forwarded webhook whose payload is the contract event, unchanged:

{
    "from": "whatsapp-web",
    "type": "forward",
    "providerConfigKey": "<INTEGRATION-ID>",
    "connectionId": "<CONNECTION-ID>",
    "payload": {
        "type": "message.received",
        "channelId": "<SESSION-ID>",
        "providerEventId": "received:3EB0C4F1A2B3",
        "payload": { "providerMessageId": "3EB0C4F1A2B3", "chat": { "type": "contact", "externalId": "[email protected]" }, "...": "..." }
    }
}
payload.typeWhen
message.receivedA message in a chat of the number, received or sent from its phone (fromMe). Received files are in payload.media; download them with GET /whatsapp/media.
message.statusA sent message is SENT, DELIVERED, READ or FAILED (with errorCode).
channel.statusThe number connects, reconnects, disconnects or is banned.
groups.syncedThe reply to POST /whatsapp/groups/sync.
command.failedA group sync could not run, for instance while the number is disconnected.

Deduplicate on payload.providerEventId: an event can arrive twice.

When the number logs out, its session becomes invalid or it is banned, the connection is flagged with an error and an auth webhook is sent, as for a failed token refresh. The number has to scan a new QR code: reconnect it with a reconnect session. The error clears when it connects again.

Self-hosting

The integration needs the WhatsApp gateway (packages/whatsapp-gateway), which runs from the same image as the server:

node packages/whatsapp-gateway/dist/app.js
VariableServerGatewayDescription
WHATSAPP_GATEWAY_URL✓Where the server reaches the gateway, e.g. http://tenklo-whatsapp:3010.
WHATSAPP_GATEWAY_SECRET✓✓Shared secret (32+ characters) for the commands and events between them.
WHATSAPP_GATEWAY_EVENTS_URL✓The server's event endpoint, e.g. http://tenklo-server:3003/internal/whatsapp/events.
WHATSAPP_SESSION_ENCRYPTION_KEY✓Encrypts the sessions at rest (32+ characters). Changing it requires pairing every number again.
WHATSAPP_S3_ENDPOINT, WHATSAPP_S3_REGION, WHATSAPP_S3_ACCESS_KEY, WHATSAPP_S3_SECRET_KEY, WHATSAPP_S3_BUCKET✓S3-compatible bucket for media.
WHATSAPP_MEDIA_MAX_BYTES✓Largest file accepted for sending (default 100 MB; the server's NANGO_SERVER_PUBLIC_BODY_LIMIT also applies).
NANGO_DB_*, NANGO_REDIS_URL✓✓The same database and Redis as the server.

Keep the gateway on the internal network: only the server needs to reach it.

On this page