Migrating from the Evolution API to Luna
The Evolution API is a layer over an unofficial library; Luna operates exclusively on Meta’s official WhatsApp Cloud API, as an approved Tech Provider. The two do similar things by different routes, and the difference in route is what this document is about.
Read the three differences below before the table. They are what breaks a migration halfway: whoever starts at the table discovers the first one with the first customer who replies the next day, with the design already built.
Three things that will change your design
1. The 24-hour window exists, and there is no way around it
On the official Cloud API, free-form text only goes out to someone who messaged you in the last 24 hours. After that, the only path is a template approved by Meta. On an unofficial library this restriction does not show up, because it talks over the same channel a handset does. A design that worked there assumes a freedom that does not exist here.
Read `GET /v1/conversations` before deciding how to reply. Each row carries `windowOpen`, `windowExpiresAt` and `windowSource`: the platform tells you **before** you spend the call, instead of returning an error afterwards. With the window closed, the path is to send a template, and it reopens the conversation.
2. Sending replies `202`, and Meta’s identifier does not come with it
A `POST /v1/messages` returns `202` with a Luna identifier and the `queued` state. It does not return the `wamid`, because at that instant Meta has not issued one yet. Anyone who integrated expecting the final identifier in the response has to change their design, and this is the second thing that breaks a migration.
The reason is honest and worth knowing: the tightest limit on the Cloud API is one message every six seconds to the same recipient. An agent that answers in bursts trips that limit on its own, and the right answer to it is a queue, never an error handed back to you. Keep the Luna identifier, and receive the `wamid` and the delivery state through your webhook, in the `message.status` event.
3. There is no QR Code, and there cannot be one
Connecting a number by scanning a QR Code with a handset is the model of a library that presents itself as a WhatsApp Web. The official platform does not work that way: each end business needs its own WhatsApp Business account with Meta, with its own payment method, and the connection happens through the flow Meta calls Embedded Signup: inside your product, but against Meta.
This is the difference with **no way around it**, and it is the reason Luna exists in the shape it does. It is also what separates a connection that survives from one that gets shut off: the number belongs to your customer, in their own account, and Helsen operates as a Meta-approved Tech Provider. The cycle starts at `POST /v1/onboarding/sessions` and ends at `POST /v1/onboarding/sessions/{id}/complete`.
Capability by capability
The map is by capability, not by route signature: promising that one call becomes another would be promising parity, and that is not the case. Where there is no equivalent, the reason is written down, and almost always it is a rule of the official platform rather than a Luna choice.
| Capability | On the Evolution API | On Luna | What to know |
|---|---|---|---|
| Send a text message | A per-instance text send route, replying with the result of the send. | POST /v1/messages | The body carries `from`, `to` and `text.body`. The response is `202` with the Luna identifier and the `queued` state; the `wamid` arrives later, over the webhook. A body with no `type` is read as text. |
| Send image, audio, video or document | A per-instance media send route, accepting the file as base64 or by URL. | POST /v1/numbers/{id}/media, depois POST /v1/messages | It is two steps because Meta works with a media identifier. Upload the file as `multipart/form-data`, get the identifier back, and use it in the send. Sending by `link` also works, and in that case the URL must answer without authentication. The identifier avoids that and can be reused across messages. |
| Start a conversation outside the 24-hour window | Usually not a concept at all: a text send works at any time. | POST /v1/templates, depois POST /v1/messages com `type: template` | It is the direct consequence of the first model difference. The template is created, submitted to Meta and used once approved, and its state arrives over webhook, with no polling on your side. |
| Receive what arrives on the number | A per-instance webhook, with the events chosen in its configuration. | POST /v1/webhooks/endpoints | One endpoint for the whole tenant, not one per number: the events say which number they came from. Every delivery is signed, and the secret can be rotated with no downtime window through `POST /v1/webhooks/endpoints/{id}/rotacionar`, which keeps the previous secret valid for a period. |
| Recover what your server did not receive | Depends on the deployment; often there is neither retry nor a record of what failed. | GET /v1/webhooks/dlq, POST /v1/webhooks/dlq/{id}/reenfileirar | An event reaches the rejected queue after the retry policy is exhausted. It is not delivered again on its own: either you requeue it, or it stays there until retention expires. The response carries no total, on purpose: to know how many items there are, page until `nextCursor` comes back null. |
| Read the message history | A query against the instance’s own storage, with per-chat filters. | GET /v1/messages, GET /v1/conversations | Filter by `phoneNumberId` or by `conversationId`, and page with `nextCursor`. The cursor is opaque: it carries position, not a count, so do not try to build one by hand. |
| Know whether a message was delivered and read | Status update events delivered by the instance webhook. | Evento `message.status` no seu webhook, e GET /v1/messages/{id} | The state arrives as an event, and the lookup by identifier is there to reconcile. This is where Meta’s `wamid` gets tied to the identifier the `202` returned at send time. |
| Download media the customer sent | Media usually comes embedded in the event, or is fetched from the instance’s storage. | GET /v1/messages/{id}/media | Luna stores the file and serves it from this route. The download URL Meta issues expires in five minutes, and storing that URL is the most common way for a retry to fail every time, which is why it is never passed through. |
| Mark as read and show “typing” | Per-instance presence and read routes. | POST /v1/messages/{id}/read, POST /v1/messages/{id}/typing | They exist and they work, and they are what makes an automated agent read as service rather than as a bot. Both act on the received message, not on the conversation. |
| Connect a new number | Create an instance and scan the QR Code with the handset that holds the number. | POST /v1/onboarding/sessions e o fluxo Embedded Signup | **No equivalent, and this is the difference with no way around it.** Onboarding is Embedded Signup because each end business needs its own WhatsApp Business account and its own payment method with Meta, which is the condition for operating as a Tech Provider. The number belongs to your customer, in their account, and that is what makes the connection survive. |
| List connected numbers and each one’s state | A list of instances, with each one’s connection state. | GET /v1/numbers, GET /v1/numbers/{id} | The Luna identifier and Meta’s are distinct fields, and the distinction matters: the send routes ask for Meta’s (`metaPhoneNumberId`), while the media and detail routes ask for Luna’s. |
| Create a group, join a group, message a group | Group routes, inherited from the library presenting itself as a handset. | No equivalent | **No equivalent.** The official Cloud API does not expose groups to a business number: the product Meta publishes is the conversation between a business and a person. This is not a Luna choice, and no official platform offers it. |
| Read the address book, profile and photo of whoever wrote | Contact and profile routes, for the same reason as the row above. | No equivalent | **No equivalent.** A business number on the official platform has no address book, and the profile of whoever writes is not published. What arrives is the correspondent’s phone number and the name they themselves exposed in the event, and that is what there is to work with. |
| Check whether a number has WhatsApp before sending | A check route, answering whether the number exists on the network. | No equivalent | **No equivalent.** The official platform does not publish that lookup. A send to a number without WhatsApp simply is not delivered, and you find out through the status event, which is the equivalent information, one step later. |
| Authenticate your calls | A global key for the deployment, usually in a custom header. | POST /v1/api-keys | The key goes in `Authorization: Bearer hlsn_…` and carries **scopes**: a route outside the set answers `403`. Give each key only what it needs: one that merely sends messages should not be able to connect numbers. It is shown exactly once, and no route hands it back afterwards. |
What Helsen charges for, and what Meta charges for
Helsen’s subscription is for your plan’s active-number capacity, and for nothing beyond that. WhatsApp platform usage is charged by Meta, directly to your end customer, on the payment method they register in their own account. Helsen does not intermediate that payment and does not pass it through. This is why each end business needs its own Meta account.
Where to start
The API documentation carries each route’s contract, with examples, and there is a TypeScript client generated from it, the same one Helsen’s own console uses. If anything in this guide disagrees with the documentation, the documentation wins: it is derived from the code that validates the requests.
WhatsApp and Meta are trademarks of Meta Platforms, Inc. The Evolution API is a third-party project, unaffiliated with Helsen. This document describes each platform’s model for those migrating, and is not a commercial comparison.