# Start an automation Source: https://docs.revoplyai.com/api-reference/hooks/start-flow-from-hook/ > Starts the flow this hook belongs to for the customer your JSON names, by the paths set in the flow's trigger. ```http POST https://api.revoplyai.com/hooks/{token} ``` Starts the flow this hook belongs to for the customer your JSON names, by the paths set in the flow's trigger. The run is queued, not finished: `202 accepted` returns its id at once, and `flow.completed` or `flow.failed` tells you how it ended. Send an `Idempotency-Key` to make retries safe. ## Parameters | Name | In | Required | Description | | --- | --- | --- | --- | | `token` | path | yes | The hook's token, as the flow builder shows it once: `rvh_` and 43 URL-safe characters. Keep it secret; regenerate it if it leaks. | | `Idempotency-Key` | header | no | Your own id for this request, such as the order number. A repeat with the same key starts nothing new and answers 200 `duplicate` with the first run's id. | ## Request body ```json { "type": "object" } ``` Example: ```json { "order": { "id": "10482", "status": "shipped" }, "customer": { "name": "نورة العتيبي", "phone": "+966501234567" } } ``` ## Responses ### 200 `duplicate`: A request with this `Idempotency-Key` already started a run; nothing new was queued. `id` is that run. ```json { "id": "8f14e45f-ceea-467a-9575-5b1e2c7d3a90", "status": "duplicate", "detail": null } ``` ### 202 `accepted`: The flow was queued for the customer the body names. `id` is the run. ```json { "id": "8f14e45f-ceea-467a-9575-5b1e2c7d3a90", "status": "accepted", "detail": null } ``` ### 400 `invalid_json`: The body is not JSON. `idempotency_key_too_long`: The `Idempotency-Key` header is longer than 200 characters. ```json { "id": null, "status": "invalid_json", "detail": null } ``` ### 403 `not_in_plan`: The account's plan does not include automations, the developer API or automated templates. ```json { "id": null, "status": "not_in_plan", "detail": null } ``` ### 404 `not_found`: No hook has this token. It may have been regenerated or deleted. ```json { "id": null, "status": "not_found", "detail": null } ``` ### 409 `flow_off`: The flow is not published, is switched off, or is beyond the plan's number of flows. `flow_not_listening`: The published flow is not started by its webhook. `channel_unavailable`: The flow's WhatsApp number is not connected. `opted_out`: The customer opted out of messages from this business. ```json { "id": null, "status": "flow_off", "detail": "The flow is not published, switched on and within the plan's flows." } ``` ### 413 `payload_too_large`: The body is over 64 KB. ```json { "id": null, "status": "payload_too_large", "detail": "Keep the body under 64 KB." } ``` ### 415 `unsupported_media_type`: The body was not sent as `application/json`. ```json { "id": null, "status": "unsupported_media_type", "detail": "Send the body as application/json." } ``` ### 422 `invalid_phone`: No phone number at the flow's phone path, or one that cannot be read. Send it in E.164, such as +9665XXXXXXXX. ```json { "id": null, "status": "invalid_phone", "detail": "No phone number at \"customer.phone\"." } ``` ### 429 `rate_limited`: More than 60 requests a minute to this hook, or 1000 an hour to the account's hooks together. `template_limit_reached`: Today's automated templates for the account are used up. `customer_limit_reached`: This customer has had 3 automated templates in the last 24 hours. Our per-address limit on hook URLs may also answer 429, as plain text. ```json { "id": null, "status": "rate_limited", "detail": null } ``` ### 503 `service_unavailable`: Hooks are paused on our side, or we could not count requests. Try again in a minute. ```json { "id": null, "status": "service_unavailable", "detail": "Try again in a minute." } ``` ## Code samples ### cURL ```bash # --fail-with-body exits non-zero on 4xx/5xx and still prints the problem JSON; # branch on its "status" field, never on "detail". curl --fail-with-body -X POST "https://api.revoplyai.com/hooks/{token}" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "order": { "id": "10482", "status": "shipped" }, "customer": { "name": "نورة العتيبي", "phone": "+966501234567" } }' ``` ### Node.js ```js import { randomUUID } from 'node:crypto'; const res = await fetch("https://api.revoplyai.com/hooks/{token}", { method: 'POST', headers: { 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json', }, body: JSON.stringify({ "order": { "id": "10482", "status": "shipped" }, "customer": { "name": "نورة العتيبي", "phone": "+966501234567" } }), }); const data = await res.json(); if (!res.ok) { // branch on data.status, never on data.detail throw new Error(`${res.status} ${data.status}: ${data.detail ?? data.title}`); } console.log(data); ``` ### Python ```python import uuid import requests res = requests.request( "POST", "https://api.revoplyai.com/hooks/{token}", headers={ "Idempotency-Key": str(uuid.uuid4()), }, json={ "order": { "id": "10482", "status": "shipped", }, "customer": { "name": "نورة العتيبي", "phone": "+966501234567", }, }, timeout=30, ) data = res.json() if not res.ok: # branch on data["status"], never on data["detail"] raise RuntimeError(f"{res.status_code} {data['status']}: {data.get('detail')}") print(data) ``` ### PHP ```php 'POST', CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Idempotency-Key: ' . uuidv4(), 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => $body, ]); $response = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $data = json_decode($response, true); if ($status >= 400) { // branch on $data['status'], never on $data['detail'] throw new RuntimeException("$status {$data['status']}: " . ($data['detail'] ?? '')); } print_r($data); ``` ### C# ```csharp using System.Net.Http.Headers; using System.Text; using System.Text.Json; using var http = new HttpClient(); using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.revoplyai.com/hooks/{token}"); request.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString()); request.Content = new StringContent( """ { "order": { "id": "10482", "status": "shipped" }, "customer": { "name": "نورة العتيبي", "phone": "+966501234567" } } """, Encoding.UTF8, "application/json"); using var response = await http.SendAsync(request); var json = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(json); if (!response.IsSuccessStatusCode) { // branch on "status", never on "detail" var code = doc.RootElement.GetProperty("status").GetString(); throw new HttpRequestException($"{(int)response.StatusCode} {code}"); } Console.WriteLine(json); ``` # API reference Source: https://docs.revoplyai.com/api-reference/ > The machine-readable contract generated from the OpenAPI specification, with the flow trigger endpoint and every webhook event. The pages in this section are generated from the OpenAPI 3.1 specification ([download](/resources/openapi/)), so they match what the API does. **What it contains today:** * [`POST /hooks/{token}`](/api-reference/hooks/start-flow-from-hook/): the endpoint your system calls to start a flow. Its token is its credential, so this page has no in-browser console: call it from your server. The guides are under [Flow triggers](/flow-triggers/). * Every [webhook event](/webhooks/events/) we send to your endpoints, with its fields and an example. **What it does not contain yet:** the REST API (`https://api.revoplyai.com/v1`) for reading contacts and conversations and sending messages is not available. Its endpoints, API keys and in-browser console will appear here when it is published, and the [changelog](/resources/changelog/) will announce it. # AI actions Source: https://docs.revoplyai.com/connect-your-systems/ai-actions/ > Calls to your API the assistant may make while it talks to a customer, and the safeguards around who they are about. An AI action is one request to your system that the assistant can decide to make during a conversation: look an order up by its number, check stock, book a callback. You describe what it does and when to use it; the assistant decides when a conversation needs it, fills in its parameters, and reads only the parts of the answer you choose. An account can have up to 20 actions. Set them up under **Integrations → AI actions**. Each action calls through an [API connection](/connect-your-systems/api-connections/). ## Parts of an action [#parts-of-an-action] | Part | Rules | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Name and tool name | The tool name is lower-case letters, digits and `_`, starting with a letter, up to 40 characters; the assistant knows the action as `action_` | | Description | Up to 500 characters: what it does, when to use it, and when not to. The assistant decides from this | | Request | Method, path, query, headers and body, as in an [HTTP step](/connect-your-systems/http-steps/); parameters are read as `{{args.name}}` | | Parameters | Up to 8, each a string, number, yes/no or one of a list of values, with a description. Required ones are asked of the customer before calling. Values up to 500 characters | | About the customer | Optional: who the action is about (below) | | What the assistant sees | Fields picked from the answer, each under a label. With none picked, the whole answer, cut short | ## Confirmation [#confirmation] **Ask the customer to confirm before calling** is on by default. The assistant first tells the customer what it is about to do, and the call is made only when they agree in a later message; a proposal not agreed to within 15 minutes has to be made again. Switch it off only for requests that read and change nothing. ## Actions about the customer [#actions-about-the-customer] For an action that looks up the customer's own orders, bookings or account, choose **who the action is about**: their phone number (as `+966…`), their email, or their RevoplyAI contact id. That value is filled in from the conversation, never by the assistant, and is read in the request as `{{identity}}`. * It is filled in only on channels that verify who the customer is, such as WhatsApp. An action about the customer is never offered on the website widget, where visitors are not verified. * The email is only as trustworthy as whoever wrote it on the contact. * **Ownership check**: name a field in the answer that must equal the customer's value. When it does not, the assistant is told nothing was found, so a guessed order number cannot reveal another customer's order. Do not add a parameter that says who the customer is (a phone or an email the assistant fills in): the assistant would take it from whatever the customer claims. ## Limits [#limits] * At most 3 action calls in one assistant reply, and 60 per customer per day (UTC). * Every call counts towards the account's daily allowance of calls to your systems, shared with HTTP steps. * Timeouts, sizes and retries are those of [requests we send](/connect-your-systems/requests-we-send/). When a call fails or times out, the assistant is told it could not check. When the values it passed are refused, it is asked to correct them. ## Try it [#try-it] **Try it** runs the saved action as the assistant would, with values you type, and shows the request, the answer and exactly what the assistant would read. A test does not wait for the customer to confirm. Tests are real calls to your system. # API connections Source: https://docs.revoplyai.com/connect-your-systems/api-connections/ > One of your systems as flows and the assistant reach it, its base URL, authentication, default headers and timeout. A connection holds where your system is and how to prove the call is from you. HTTP steps and AI actions choose a connection by name and add their own path, so a URL or a credential is changed in one place. An account can have up to 10 connections. Set them up under **Integrations → API connections → Add connection**. ## Fields [#fields] | Field | Rules | | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Name | Up to 80 characters; how steps and actions pick it | | Base URL | `https://` only; no username or password, query string or `#fragment`; no `.` or `..` path segments; up to 500 characters. Every call starts here, such as `https://api.example.com/v1` | | Authentication | One of the kinds below | | Default headers | Up to 10, sent with every call, such as a store id or an API version | | Timeout | 1–15 seconds per call, 10 by default | The base URL must resolve to public IP addresses only; this is checked when you save it and again at every connection. ## Authentication [#authentication] | Kind | Sent as | | -------------------- | ---------------------------------------------------------------------------- | | None | No credential. Only for an address anyone may call. | | Bearer token | `Authorization: Bearer ` | | Basic | `Authorization: Basic ` | | API key in a header | The key in a header you name, such as `X-Api-Key` | | API key in the query | The key as a query parameter you name. Prefer a header: URLs end up in logs. | The credential is stored encrypted and is never shown again; the dashboard shows at most its last four characters. It is added to each request last, after every template is filled in, so nothing a customer writes can replace it, and it is removed from any answer before a flow or the assistant reads it. Credentials up to 4,096 characters are accepted. Give each connection a credential of its own, with only the permissions its steps and actions need. Revoke it in your system if it may have leaked, then replace it here. ## Test, switch off, delete [#test-switch-off-delete] * **Test** sends a request through the connection now and shows the answer. Tests are real calls and are limited to 10 a minute per account. * **Switch off** stops every call through it: HTTP steps take their failure path and the assistant is not offered its actions. You can still test it. * A connection used by steps or actions cannot be deleted until they stop using it. # HTTP steps Source: https://docs.revoplyai.com/connect-your-systems/http-steps/ > A flow step that calls your API through a connection, keeps values from the answer and branches on success or failure. The **Call your system** step in the flow builder makes one request through an [API connection](/connect-your-systems/api-connections/), keeps values from the answer as flow variables, and continues on its **success** path after a `2xx` answer or its **Failed** path after anything else. ## The request [#the-request] | Part | Rules | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Method | `GET`, `POST`, `PUT`, `PATCH` or `DELETE` | | Path | Added to the connection's base URL, such as `/orders/{{vars.order_id}}`. Starts with `/`; no `.` or `..` segments, query or fragment | | Query parameters | Up to 10; names and values are percent-encoded for you | | Headers | Up to 10, in addition to the connection's. Credentials and transport headers cannot be set: `Authorization`, `Cookie`, `Host`, `Content-Type`, `Content-Length` and similar, and anything starting `Proxy-`, `Sec-` or `X-Forwarded-` | | Body | `POST`, `PUT` and `PATCH` only: a JSON object or array, sent as `application/json` | Values from the conversation are inserted with placeholders such as `{{vars.order_id}}`; the builder's **Insert** menu lists what is available. Each value is escaped for where it lands: * in the path, it is percent-encoded. The call is not made when a value in the path comes out empty (`/orders/` is not the question `/orders/{{vars.order_id}}` asked) or contains `/`, `\` or `..`, which could walk out of the base path at a proxy that decodes it; * in a query parameter, it is percent-encoded, and a parameter whose value comes out empty is left out; * in the body, write each placeholder inside quotes (`"{{vars.order_id}}"`); the value is written as a JSON string, escaped. ## The answer [#the-answer] * Up to 10 values can be kept from a JSON answer, each by a [path](/flow-triggers/sending-requests/#paths) such as `data.order.status`, as a variable for later steps. * Every call also sets `vars.http_status` to the answer's status code, such as `200` or `404`, or leaves it empty when no answer came. * An answer larger than 256 KB counts as a failure, and nothing is kept from it. ## Failures and retries [#failures-and-retries] The Failed path is taken when the answer is not `2xx`, no answer comes within the connection's timeout, the connection cannot be made, or the call is not made at all (the connection is switched off, the account's daily allowance is used up, a path value is empty). Add a step there so the customer is not left waiting. A `GET` that times out, cannot connect or gets a `5xx` answer is tried once more. Other methods are never repeated: the first attempt may have worked and only its answer been lost. A flow makes at most 3 calls in a row between two customer messages, within 30 seconds in all, retries included. ## Test the request [#test-the-request] **Test the request** in the step sends it once with sample values you type and shows the answer, so you can tap the values to keep. Tests are real calls to your system. # Connect your systems Source: https://docs.revoplyai.com/connect-your-systems/ > How flows and the assistant call your own API through API connections, HTTP steps and AI actions. Your store, CRM or booking system knows things RevoplyAI does not: where an order is, whether an item is in stock, which slots are free. Three pieces let flows and the assistant ask it: | Piece | What it is | | -------------------------------------------------------- | -------------------------------------------------------------------------------- | | [API connection](/connect-your-systems/api-connections/) | Your system's base URL and how to authenticate to it. Set up once, used by name. | | [HTTP step](/connect-your-systems/http-steps/) | A step in a flow that calls your API and keeps values from the answer. | | [AI action](/connect-your-systems/ai-actions/) | A call the assistant may decide to make while it talks to a customer. | All three are part of the Business plan, and are managed by an Owner or an Admin under **Integrations** (connections and actions) and in the flow builder (HTTP steps). The direction matters: * These pages are about **us calling you**, in the middle of a conversation. * [Webhooks](/webhooks/) are us telling you something happened, after the fact. * [Flow triggers](/flow-triggers/) are you calling us to start a flow. Every call goes out from our servers under the same rules: HTTPS to a public address, no redirects, strict timeouts and size limits, and a daily allowance per account. See [Requests we send](/connect-your-systems/requests-we-send/). # Requests we send Source: https://docs.revoplyai.com/connect-your-systems/requests-we-send/ > What every request from RevoplyAI to your systems looks like, the addresses we refuse, and the timeouts, sizes and daily limits. This page covers every request our servers make to yours: webhook deliveries, HTTP steps, AI actions, and the test buttons for each. ## Headers [#headers] | Header | Webhook deliveries | HTTP steps and AI actions | | -------------- | --------------------------------- | ------------------------------------------------------- | | `User-Agent` | `RevoplyAI-Webhooks/1.0` | `RevoplyAI-Automations/1.0` | | `Content-Type` | `application/json; charset=utf-8` | `application/json; charset=utf-8`, when there is a body | | `Accept` | — | `application/json` | | Authentication | `X-Revoply-Signature` | The connection's credential | HTTP steps and AI actions send the connection's default headers, then the step's or action's own headers, then the credential, which nothing before it can replace. Header values filled in from Arabic text are sent as UTF-8. ## Addresses we refuse [#addresses-we-refuse] Every URL we call is under your control, and our servers make the call, so both are checked: * `https://` only, with a host name that contains a dot. No username or password in the URL. * Names ending in `.local`, `.internal`, `.localdomain` or `.home.arpa` are refused. * Every address the name resolves to must be public. Refused: private ranges (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback, link-local (including `169.254.169.254`), carrier-grade NAT (`100.64.0.0/10`), multicast and reserved ranges, documentation and benchmarking ranges, IPv6 addresses outside global unicast (including `fc00::/7` and `fe80::/10`), and NAT64, 6to4 and Teredo addresses that lead to any of these. * The check runs when you save the URL and again at every connection, so a name pointed at a private address after it was saved still cannot be reached. * No redirects are followed; a `3xx` answer is a failure. No proxies, no cookies. We do not publish a fixed list of IP addresses our requests come from. Authenticate them by signature or credential, not by address. ## Timeouts [#timeouts] | Request | Limit | | --------------------------- | -------------------------------------------------------------------------- | | Webhook delivery | 10 seconds for the whole request, answer included | | HTTP step or AI action call | The connection's timeout, 1–15 seconds (10 by default) | | Opening the connection | 5 seconds, within the above | | Calls in one go | 3 calls between two customer messages, 30 seconds in all, retries included | The time counts until the whole answer is read, not only its headers. ## Sizes [#sizes] | What | Limit | | ---------------------------------------- | ------------------------------------ | | Webhook body | 64 KB | | Webhook answer kept in the delivery log | First 2 KB | | HTTP step or action body, once filled in | 64 KB | | Path and query, once filled in | 4,096 characters | | Answer to an HTTP step or action | 256 KB; a larger answer is a failure | ## Daily allowance [#daily-allowance] An account's HTTP steps and AI actions may make 10,000 calls a day together (UTC), every attempt counted, retries and tests included. After that, calls are not made until the next day: steps take their Failed path and actions tell the assistant they could not check. Webhook deliveries do not count towards it. ## What we log [#what-we-log] For each call to your API we keep the host, the path without its query string, the status, the duration and whether it was a retry, for 14 days. We do not log request or response bodies, which can hold customers' details. Credentials are removed from any answer before a flow or the assistant reads it. # Flow triggers Source: https://docs.revoplyai.com/flow-triggers/ > Start a WhatsApp flow for a customer when something happens in your system, by posting JSON to the flow's private URL. A flow whose trigger is **Your system sends an event** gets a private URL. When your system posts JSON to it (an order shipped, a form filled in, a payment failed), the flow starts for the customer the JSON names, on your WhatsApp Business number. ```http POST https://api.revoplyai.com/hooks/rvh_EXAMPLE… Content-Type: application/json Idempotency-Key: order-10482-shipped ``` Flow triggers are part of the Business plan. Only an Owner or an Admin can set them up. ## Rules [#rules] * **WhatsApp Business numbers only.** The flow sends from a `whatsapp` channel: a number connected through Meta. A WhatsApp QR number cannot be used. * **The flow must start with a template.** The customer has not written, so no service window is open; the flow cannot be published until it sends a template before anything else. See [WhatsApp messaging rules](/get-started/whatsapp-messaging-rules/). * **The URL is the credential.** There is no API key: anyone who has the URL can start the flow. Call it from your server only, never from a browser or an app. * An account can have up to 20 flow trigger URLs. ## Set one up [#set-one-up] 1. In **Automations**, create a flow and choose the trigger **Your system sends an event**. 2. Choose the **WhatsApp number** the flow messages customers from. 3. **Create the URL** and copy it. It is shown in full only once; afterwards the builder shows only its start. 4. Say where the customer's **phone number** is in your JSON, such as `customer.phone`. Optionally, say where their name and language are, and which values to keep as variables for the flow's steps. Send one request first and the builder offers its fields to tap. See [Sending requests](/flow-triggers/sending-requests/). 5. Build the flow, starting with a template step, then publish it and switch it on. ## What happens when a request is accepted [#what-happens-when-a-request-is-accepted] 1. We find the contact with that number, or create one (a new contact is tagged `webhook`), and the WhatsApp conversation with them on the flow's number, or create it. 2. A run of the flow is queued, and you get `202` with `"status": "accepted"` and the run's `id` at once. The run has not sent anything yet. 3. When the run ends, [`flow.completed`](/webhooks/events/flow-completed/) or [`flow.failed`](/webhooks/events/flow-failed/) carries the same run `id`, if an endpoint subscribes to them. When the customer answers, their reply lands in the conversation the flow is waiting in. Every other answer is explained in [Responses and errors](/flow-triggers/responses-and-errors/). ## Replace the URL [#replace-the-url] **Replace the URL** in the trigger makes a new URL and shows it once. The old URL stops working at once and answers `404 not_found`; update every system that sends to it. Replace it whenever it may have leaked. [Idempotency keys](/get-started/idempotency/) already used on the flow stay used. ## When requests are refused on our side [#when-requests-are-refused-on-our-side] If flow triggers are paused on our side, or we cannot count requests, every request is answered `503 service_unavailable`. Retry later with the same `Idempotency-Key`. # Responses and errors Source: https://docs.revoplyai.com/flow-triggers/responses-and-errors/ > The JSON every flow trigger request is answered with, every status it can carry, and which ones to retry. Every answer from a flow trigger URL has the same JSON body, whatever its HTTP status: ```json { "id": "8f14e45f-ceea-467a-9575-5b1e2c7d3a90", "status": "accepted", "detail": null } ``` | Field | Type | Meaning | | -------- | ---------------- | -------------------------------------------------------------------------------------------------------------- | | `id` | UUID or `null` | The run the request started, or, for a repeat, the run the first request started. `null` when nothing started. | | `status` | string | `accepted`, `duplicate`, or why not. Branch on this. | | `detail` | string or `null` | Why, in words for people, such as `No phone number at "customer.phone".` Log it; it may change. | The one exception: when one IP address sends more than 120 requests a minute to flow trigger URLs, it is answered `429` with the plain-text body `Too many requests`. Treat any `429` without JSON as `rate_limited`. ## Every status [#every-status] Generated from the list the API itself answers with: | `status` | HTTP | Retry | Meaning | What to do | | -------------------------- | ---- | ----- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | `accepted` | 202 | — | The flow was queued for the customer the body names. `id` is the run. | Done. Keep `id` to match the `flow.completed` or `flow.failed` event. | | `duplicate` | 200 | — | A request with this `Idempotency-Key` already started a run; nothing new was queued. `id` is that run. | Treat as success: `id` is the run the first request started. | | `invalid_json` | 400 | No | The body is not JSON. | Fix the body. The same request fails the same way every time. | | `idempotency_key_too_long` | 400 | No | The `Idempotency-Key` header is longer than 200 characters. | Send a key of at most 200 characters. | | `not_in_plan` | 403 | No | The account's plan does not include automations, the developer API or automated templates. | Stop sending. Nothing starts until the account's plan includes it. | | `not_found` | 404 | No | No hook has this token. It may have been regenerated or deleted. | Check the URL: it may have been replaced or the flow deleted. | | `flow_off` | 409 | No | The flow is not published, is switched off, or is beyond the plan's number of flows. | Publish the flow and switch it on, then send again. | | `flow_not_listening` | 409 | No | The published flow is not started by its webhook. | Set the published flow's trigger to "Your system sends an event", then send again. | | `channel_unavailable` | 409 | Yes | The flow's WhatsApp number is not connected. | Reconnect the flow's WhatsApp number; retry later. | | `opted_out` | 409 | No | The customer opted out of messages from this business. | Do not retry: the customer asked not to be messaged. | | `payload_too_large` | 413 | No | The body is over 64 KB. | Send only what the flow reads. | | `unsupported_media_type` | 415 | No | The body was not sent as `application/json`. | Send `Content-Type: application/json`. | | `invalid_phone` | 422 | No | No phone number at the flow's phone path, or one that cannot be read. Send it in E.164, such as +9665XXXXXXXX. | Send the number with its country code, such as `+966501234567`. | | `rate_limited` | 429 | Yes | More than 60 requests a minute to this hook, or 1000 an hour to the account's hooks together. | Retry with backoff. | | `template_limit_reached` | 429 | Yes | Today's automated templates for the account are used up. | Retry later, when the day's allowance frees up, or drop the event. | | `customer_limit_reached` | 429 | Yes | This customer has had 3 automated templates in the last 24 hours. | Retry later, or drop the event. | | `service_unavailable` | 503 | Yes | Hooks are paused on our side, or we could not count requests. Try again in a minute. | Retry after a minute, with the same `Idempotency-Key`. | `status` may gain values. Treat one you do not know as a failure you did not expect: log it with its `detail`, and use the HTTP status to decide whether to retry. ## Retry or not [#retry-or-not] * **Retry later**, with the same `Idempotency-Key`: `rate_limited`, `template_limit_reached`, `customer_limit_reached`, `channel_unavailable`, `service_unavailable`, any `5xx`, and a timeout or a connection that failed. Back off exponentially, starting at about a minute. * **Do not retry unchanged**: every other status. The same request fails the same way until you change the request, the flow or its settings. * **Never retry** `opted_out`: the customer asked not to be messaged. * `accepted` and `duplicate` are both success. A `duplicate` means an earlier request with the same key already started the run whose `id` you receive. ## Order of checks [#order-of-checks] A request is checked in this order, and the first failure is the answer: 1. the per-address limit (`429`, plain text); 2. `Content-Type` (`415`) and size (`413`); 3. whether flow triggers are paused on our side (`503`); 4. that the URL's token exists (`404`); 5. the per-URL and per-account rate limits (`429`, `503` if we cannot count); 6. the `Idempotency-Key` length and that the body is JSON (`400`); 7. the account's plan (`403`); 8. that the flow is published, switched on and started by this trigger (`409`); 9. the `Idempotency-Key` against earlier runs (`200 duplicate`); 10. the flow's WhatsApp number (`409 channel_unavailable`); 11. the customer's phone number (`422`); 12. the account's and the customer's template limits (`403`, `429`); 13. whether the customer opted out (`409 opted_out`). A request refused at any step starts nothing, and its `Idempotency-Key` stays unused. ## After `202 accepted` [#after-202-accepted] The run is queued, not finished. It can still end without sending anything, for example when someone on your team is already handling the conversation. Subscribe an endpoint to [`flow.completed`](/webhooks/events/flow-completed/) and [`flow.failed`](/webhooks/events/flow-failed/) to learn how a run ended; both carry the run `id` you received. A run that is skipped or cancelled before it ends sends neither event. # Sending requests Source: https://docs.revoplyai.com/flow-triggers/sending-requests/ > The request a flow trigger accepts, the path syntax for mapping fields, and how phone numbers and languages are read. ## The request [#the-request] | Part | Rule | | ----------------- | ------------------------------------------------------------------------------------------------------------------------- | | Method and URL | `POST` to the flow's URL, `https://api.revoplyai.com/hooks/rvh_…` | | `Content-Type` | `application/json` (or any `+json` type); anything else is `415 unsupported_media_type` | | Body | Any JSON, up to 64 KB (65,536 bytes); larger is `413 payload_too_large` | | `Idempotency-Key` | Optional, strongly recommended: your id for the event, up to 200 characters. See [Idempotency](/get-started/idempotency/) | | Authentication | None besides the URL: its token is the credential | Send the JSON your system already produces. The flow's trigger settings say where in it to find the customer's phone number, name and language, and which values to keep. cURL Node.js Python ```bash curl --silent --show-error "$REVOPLY_HOOK_URL" \ --header 'Content-Type: application/json' \ --header 'Idempotency-Key: order-10482-shipped' \ --data '{ "order": { "id": "10482", "status": "shipped" }, "customer": { "name": "نورة العتيبي", "phone": "+966501234567" } }' \ --write-out '\nHTTP %{http_code}\n' ``` ```js /** The outcomes worth trying again later with the same Idempotency-Key. */ const RETRYABLE = new Set([ 'channel_unavailable', 'rate_limited', 'template_limit_reached', 'customer_limit_reached', 'service_unavailable', ]); export async function startFlow(hookUrl, payload, idempotencyKey, { fetchImpl = fetch } = {}) { const response = await fetchImpl(hookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey }, body: JSON.stringify(payload), }); // Every answer is { id, status, detail }, except a 429 from the per-address limit in front // of the hooks, which is plain text. const outcome = await response.json().catch(() => ({ id: null, status: null, detail: null })); const status = outcome.status ?? (response.status === 429 ? 'rate_limited' : 'unexpected'); return { ok: status === 'accepted' || status === 'duplicate', retry: RETRYABLE.has(status) || response.status >= 500, httpStatus: response.status, runId: outcome.id, status, detail: outcome.detail, }; } ``` ```python # The outcomes worth trying again later with the same Idempotency-Key. RETRYABLE = { "channel_unavailable", "rate_limited", "template_limit_reached", "customer_limit_reached", "service_unavailable", } def start_flow(hook_url: str, payload: dict, idempotency_key: str) -> dict: request = urllib.request.Request( hook_url, data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), headers={"Content-Type": "application/json", "Idempotency-Key": idempotency_key}, method="POST", ) try: with urllib.request.urlopen(request, timeout=30) as response: http_status, body = response.status, response.read() except urllib.error.HTTPError as error: # 4xx and 5xx still carry { id, status, detail } http_status, body = error.code, error.read() try: outcome = json.loads(body) except ValueError: # the per-address limit in front of the hooks answers 429 as plain text outcome = {"id": None, "status": "rate_limited" if http_status == 429 else "unexpected", "detail": None} status = outcome.get("status") return { "ok": status in ("accepted", "duplicate"), "retry": status in RETRYABLE or http_status >= 500, "http_status": http_status, "run_id": outcome.get("id"), "status": status, "detail": outcome.get("detail"), } ``` ## Paths [#paths] A path names one value in your JSON: property names joined by dots, and array positions in square brackets. | Path | Reads | | --------------------------- | ------------------------------------------------------- | | `customer.phone` | `{"customer": {"phone": "+966501234567"}}` | | `items[0].name` | The `name` of the first item in `items` | | `$.order.id` | `order.id`; a leading `$` or `$.` is ignored | | `[0].id` | The `id` of the first element when the body is an array | | `order-id`, `العميل.الجوال` | Keys with dashes or Arabic letters, as written | * Property names are taken literally: any character except `.`, `[`, `]` and spaces. There is no quoting, so a key that contains a dot cannot be reached. * No wildcards, filters or slices. A path is at most 200 characters and 12 steps. * A string is read as it is; a number or `true`/`false` as JSON writes it; an object or an array as compact JSON. A missing value or `null` counts as empty. Spaces around a value are removed. ## What the trigger reads [#what-the-trigger-reads] | Setting | Required | Read as | | ----------------------- | -------- | ------------------------------------------------------------------------ | | Customer's phone number | Yes | A phone number; see below. Missing or unreadable: `422 invalid_phone` | | Customer's name | No | Up to 200 characters; used for a contact we create | | Customer's language | No | `ar` or `en`; see below | | Values to keep | No | Up to 10, each up to 1,000 characters, as variables for the flow's steps | ### Phone numbers [#phone-numbers] Send numbers in E.164, such as `+966501234567`. We also read: * spaces, dashes, brackets and Arabic-Indic digits (`٠٥٠١٢٣٤٥٦٧`), which are ignored or converted; * `00` in place of `+`: `00966501234567`; * the country code without `+`: `966501234567`; * a stray `0` after the country code of the countries listed below: `+9660501234567`. A number without its country code, such as `0501234567`, is read as a mobile number in the country of the flow's WhatsApp number, and only when that country is Saudi Arabia, the United Arab Emirates, Kuwait, Qatar, Bahrain, Oman or Egypt. Anywhere else it is refused with `422 invalid_phone` rather than guessed: a number placed in the wrong country would send your message to a stranger. ### Language [#language] The run uses this language. Values starting with `ar` (`ar`, `ar-SA`), and `arabic`, `عربي` or `العربية`, mean Arabic; values starting with `en`, and `english`, mean English; letter case is ignored. Anything else, or no value, falls back to the language on the customer's contact. ## The last request [#the-last-request] The builder keeps the last request each URL received, so you can map fields by tapping them. It keeps the body's shape, not all of it: long lists are cut to their first 3 items and long strings to their first 200 characters. It is kept for 30 days after it arrived. Every new request with a JSON body replaces it, even one refused for another reason, such as a flow that is switched off or a phone number that cannot be read. # Channels and capabilities Source: https://docs.revoplyai.com/get-started/channels-and-capabilities/ > The channel types events carry, and what each channel can send and report back. Every conversation is on one channel, and every event names its type as `channelType`. The list may grow: treat a value you do not know as another channel, never as an error. | `channelType` | Channel | | ------------- | --------------------------------------------------------------------------------------------------------------------------- | | `whatsapp` | A WhatsApp Business number connected through Meta (the Cloud API), including numbers also used in the WhatsApp Business app | | `whatsapp_qr` | A WhatsApp number linked by scanning a QR code | | `telegram` | A Telegram bot | | `messenger` | A Facebook page's Messenger | | `instagram` | Instagram direct messages | | `web_widget` | The [website widget](/widget/) | | `website` | A website channel; nothing can be sent through it | | `voice` | A phone number the assistant answers by voice | ## What each channel can do [#what-each-channel-can-do] Generated from the channel rules the platform enforces: | `channelType` | Replies | Templates | Starts conversations | Media | Receipts | Longest text | | ------------- | ---------------------------------------------------------------------------------------------------------------------- | --------- | -------------------- | ----- | ----------------------------- | ------------ | | `whatsapp` | Within 24 hours of the customer's last message; after that, only a template. | Yes | Yes | Yes | `delivered`, `read`, `failed` | 4,096 | | `whatsapp_qr` | Only when the customer wrote within the last 72 hours, paced 8 to 20 seconds apart and at most 250 an hour per number. | No | No | Yes | None | 4,096 | | `telegram` | At any time, in a conversation the customer started. | No | No | Yes | None | 4,096 | | `messenger` | Within 24 hours of the customer's last message. | No | No | Yes | `delivered`, `read` | 2,000 | | `instagram` | Within 24 hours of the customer's last message. | No | No | Yes | `read` | 1,000 | | `web_widget` | At any time, in a conversation the visitor started. | No | No | Yes | None | 4,096 | | `website` | No | No | No | No | None | — | | `voice` | No | No | No | No | None | — | * **Replies**: when a message that is not a template may be sent to the customer. * **Templates**: whether Meta-approved templates can be sent. Only `whatsapp` numbers can; see [WhatsApp messaging rules](/get-started/whatsapp-messaging-rules/). * **Starts conversations**: whether a business can message a customer who has never written. Only with a template, on `whatsapp`. * **Receipts**: the delivery statuses the provider reports for messages the business sends. [Flow triggers](/flow-triggers/) start flows on `whatsapp` numbers only, because a flow started by your system must open with a template. # Idempotency Source: https://docs.revoplyai.com/get-started/idempotency/ > How an Idempotency-Key makes flow trigger retries safe, how long keys are remembered, and how to deduplicate our events. Networks lose answers. A request you retry may already have been accepted, and an event we retry may already have reached you. Both directions have a way to make a repeat harmless. ## Requests you send to flow triggers [#requests-you-send-to-flow-triggers] Send an `Idempotency-Key` header with every request to a [flow trigger](/flow-triggers/) URL. Use an id your system already has for the event, such as `order-10482-shipped`, and send the same key on every retry of that event. * A request whose key already started a run of the same flow starts nothing new. It is answered `200` with `"status": "duplicate"` and the `id` of the run the first request started, whether that run is still going or has ended. * Keys are scoped to the flow: the same key sent to another flow's URL starts that flow. Replacing a flow's URL keeps its keys. * A key is remembered for as long as the run it started exists. Ended runs are deleted 90 days after they end, and the key goes with them. * Only a request that started a run is remembered. Any other answer (`invalid_phone`, `flow_off`, `rate_limited`, `service_unavailable`…) leaves nothing behind, so retrying with the same key after fixing the cause works. * The body is not compared. A second request with the same key and a different body is answered `duplicate` and its body is ignored. * Keys are at most 200 characters (`400 idempotency_key_too_long` otherwise), compared exactly and case-sensitively after leading and trailing spaces are removed. An empty header is the same as none. Without a key, every accepted request starts a run, including a retry of one that was already accepted. ## Events we send you [#events-we-send-you] Webhook delivery is at-least-once: the same event can reach you more than once, after a retry or a resend. Every delivery of one event carries the same envelope `id` (`evt_…`). Record the ids you have handled and acknowledge a repeat with `2xx` without acting on it again. Keep ids for at least as long as we might send the event again: retries run for about two days, and any delivery in the 30-day delivery log can be resent by hand. See [Retries and the delivery log](/webhooks/retries-and-delivery-log/). # Identifiers Source: https://docs.revoplyai.com/get-started/identifiers/ > Phone numbers, WhatsApp ids, other channels' user ids and our own ids, and when a phone number is null. ## Phone numbers: `phone` [#phone-numbers-phone] `phone` is always an [E.164](https://en.wikipedia.org/wiki/E.164) number, such as `+966501234567`, or `null`. It is never a WhatsApp user id, and never a number without its country code. `phone` is `null` when: * the customer wrote on Messenger, Instagram, Telegram or the website widget: those channels do not share a phone number; * WhatsApp withheld the number and named the customer by a user id instead (see below); * the number on a contact was saved without a country code, such as `0501234567`. We do not guess the country. ## WhatsApp ids: `whatsAppId` [#whatsapp-ids-whatsappid] WhatsApp does not always tell a business the customer's number. It names every customer by one of: | Form | Example | Seen on | | ------------------------------- | --------------------- | ------------------------------------------------------ | | `wa_id`: the number | `966501234567` | WhatsApp Business numbers and WhatsApp QR | | Business-scoped user id (BSUID) | `EG.1525166372698999` | WhatsApp Business numbers, when the number is withheld | | `@lid` | `123456789012345@lid` | WhatsApp QR, when the number is withheld | In `message.received`, `conversation.started`, `conversation.handover`, `conversation.resolved` and `flow.*` events, `contact.whatsAppId` is the id the conversation is filed under: for a number, the digits without `+` (and `phone` holds the same number in E.164); for a BSUID or an `@lid`, the id as WhatsApp sent it (and `phone` is `null`). It is `null` on every other channel. In `contact.*` events, `whatsAppId` is the BSUID or `@lid` a contact is filed under, and `null` when only their number is known. In `appointment.*` events it is always `null`, and `phone` is set only when the booking recorded the number with its country code. A BSUID's digits and an `@lid`'s digits are not phone numbers. Do not dial them, and do not match them against the numbers in your CRM. ## Other channels [#other-channels] Messenger, Instagram and Telegram name customers by ids they issue for your page, account or bot: a page-scoped id (PSID), an Instagram-scoped id (IGSID) or a Telegram chat id. These are not phone numbers and are not sent as `phone` or `whatsAppId`. Use `contact.id` to refer to such a customer. Website widget visitors have no contact: their `contact.id` is `null`. ## Our ids [#our-ids] | Id | Format | Example | | ---------------------- | ------------------------------------------------ | ----------------------------------------------- | | `company.id` | UUID | `0b3d6f2e-8a41-4c75-9e20-5f7a1c3d9b84` | | `channelId` | UUID | `7c1e4b2a-9d3f-4e58-a6b1-2f0c8d7e5a13` | | `contact.id` | UUID | `3f6a2d18-0b7c-4e91-8a5d-c24e1f9b7a60` | | `conversation.id` | Opaque string | `WhatsApp_3f6a2d18-0b7c-4e91-8a5d-c24e1f9b7a60` | | `message.id` | Opaque string; received messages start with `m_` | `m_wamid.HBgMOTY2NTAxMjM0NTY3FQIAEhgg…` | | Event `id` | `evt_` and 32 hex characters | `evt_5dabddce7c097ab703f21e8fdf0ab52b` | | `X-Revoply-Delivery` | UUID | `5a9e2c7b-1f34-4d86-b0a5-3c8e7d2f1b69` | | `flow.id`, `run.id` | UUID | `b16d4f8a-2c9e-4b73-a5d1-0e8f3c7a9b25` | | Flow trigger token | `rvh_` and 43 URL-safe characters | `rvh_EXAMPLE…` | | Webhook signing secret | `whsec_` and 43 URL-safe characters | `whsec_EXAMPLE…` | Treat every id as an opaque string. Conversation and message ids can contain `_`, `.`, `+`, `=` and `@`, can be long (store them as text, up to 1,500 bytes), and their shape differs between older and newer conversations: never parse a channel or a phone number out of them. URL-encode them when you put them in a URL. # Introduction Source: https://docs.revoplyai.com/get-started/introduction/ > The concepts these docs use, and the JSON conventions every event and response follows. RevoplyAI answers your customers on WhatsApp, Instagram, Messenger, Telegram and your website. These docs cover the ways your own systems connect to it: * **[Webhooks](/webhooks/)**: we post signed events to your HTTPS endpoint as they happen. * **[Flow triggers](/flow-triggers/)**: your system posts to a private URL to start a WhatsApp flow for a customer. * **[API connections, HTTP steps and AI actions](/connect-your-systems/)**: flows and the assistant call your API. * **[The website widget](/widget/)**: the chat panel on your site, and its JavaScript API. A REST API for reading and sending (`/v1`) is not available yet. The [API reference](/api-reference/) documents what exists today: the flow trigger endpoint and the webhook events. ## Concepts [#concepts] **Company.** One RevoplyAI account: its channels, contacts, conversations, flows and settings. Every event names the company it happened in as `company.id`. **Project.** A separate company inside the same account, with its own channels, contacts, flows and webhook endpoints. Events from a project carry the project's `company.id`. Daily allowances, such as the templates automations may send, are shared by the account and its projects. **Channel.** A connected WhatsApp number, Instagram account, Facebook page, Telegram bot or website widget. Events carry its `channelId` and its `channelType`, such as `whatsapp` or `web_widget`; see [Channels and capabilities](/get-started/channels-and-capabilities/). **Contact.** A customer's record: name, phone, WhatsApp id, email, language, tags, custom fields and whether they opted out. Website widget visitors have no contact. **Conversation.** One customer on one channel, with its messages. The assistant answers in it until it hands the conversation over to your team. **Message.** What a customer wrote or sent: text, or media with an optional caption. **Template.** A WhatsApp message approved by Meta in advance. Only a template can start a WhatsApp conversation or reach a customer more than 24 hours after they last wrote; see [WhatsApp messaging rules](/get-started/whatsapp-messaging-rules/). **Flow.** An automation built in the dashboard (**Automations**). A trigger starts a *run* for one customer: a keyword, a new conversation, a tapped template button, a teammate, or [your system](/flow-triggers/). Runs end with `flow.completed` or `flow.failed`. ## JSON conventions [#json-conventions] * Bodies are UTF-8 JSON with camelCase keys. Arabic text arrives as-is, not escaped. * Ids are opaque strings. Store them as they are: conversation and message ids can contain `_`, `.`, `+`, `=` and `@`. See [Identifiers](/get-started/identifiers/). * Times are ISO 8601 in UTC, such as `2026-10-01T09:30:05+00:00`. * A field that can be empty is sent as `null`, never left out. * Fields are only ever added. Ignore fields you do not know. * Our own vocabularies (`channelType`, handover `reason`, event `type`) are lower-case snake_case strings that may gain values. Treat an unknown value as "other", never as an error. Contact names, custom fields and message text are often Arabic. JSON always reads left to right, whatever the values contain: ```json { "name": "محمد العلي", "phone": "+966501234567", "language": "ar", "tags": ["vip", "عميل جديد"], "fields": { "city": "دبي", "orderNote": "يرجى التوصيل بعد الساعة 5 مساءً" } } ``` # Opt-out and compliance Source: https://docs.revoplyai.com/get-started/opt-out-and-compliance/ > How customers opt out, what an opt-out stops, how your system hears about it, and how long we keep integration data. ## How a customer opts out [#how-a-customer-opts-out] A contact is opted out when: * they send a message that is just `STOP` (any letter case) on WhatsApp, WhatsApp QR, Telegram, Messenger or Instagram; * they follow the opt-out link in a broadcast; * someone on your team marks them opted out in the dashboard. They are opted back in when they send `START`, or when someone on your team opts them back in. Opt a customer back in only when they asked for it. ## What an opt-out stops [#what-an-opt-out-stops] | What | For an opted-out contact | | -------------------------------- | --------------------------------------------------------------- | | Broadcasts | Skipped | | Flows | Do not start; a run waiting for its next step ends | | [Flow triggers](/flow-triggers/) | Refused with `409` and `"status": "opted_out"`; nothing is sent | Treat `opted_out` as final for that event: do not retry it, and do not work around it by messaging the customer another way. ## How your system hears about it [#how-your-system-hears-about-it] * [`contact.opted_out`](/webhooks/events/contact-opted-out/) fires when a contact opts out. It does not fire again for a contact who is already opted out. * Opting back in is [`contact.updated`](/webhooks/events/contact-updated/) with `"optedOut": false`. * Every `contact.*` event carries the contact's current `optedOut`. If your system sends messages to customers through other tools, apply the opt-out there too. ## Data your integration receives [#data-your-integration-receives] Events carry customers' names, phone numbers and what they wrote. That is why endpoints must use HTTPS and every request is signed. On your side, keep the signing secret on your server, store only the fields you use, and delete them on the schedule your own policies set. ## How long we keep integration data [#how-long-we-keep-integration-data] | Data | Kept | | ------------------------------------------------------------------------------- | ------------------------------------------------------ | | Webhook delivery log: each delivery, its body and the first 2 KB of your answer | 30 days after the delivery was created | | Events waiting to be delivered | 7 days | | The last request a flow trigger URL received, kept for mapping fields | 30 days after it arrived; each new request replaces it | | Flow runs and the values they collected | 90 days after the run ends | | A run's step-by-step history | 30 days | | Log of calls to your API: host, path, status and timing, no bodies | 14 days | | Records of templates automations sent | 30 days | Conversations and contacts are not part of this schedule. # Plans and limits Source: https://docs.revoplyai.com/get-started/plans-and-limits/ > Which plans include webhooks, flow triggers and API connections, and the bounds each of them enforces. Webhooks, flow triggers, API connections and AI actions are part of the Business plan. Every number on this page is generated from the code that enforces it. ## By plan [#by-plan] | | Free | Starter | Pro | Business | | ------------------------------------------- | ---- | ------- | --- | -------- | | Webhooks, flow triggers and API connections | No | No | No | Yes | | Automations | No | No | Yes | Yes | | Automation flows | 0 | 0 | 5 | 25 | | Templates automations may send per day | 0 | 0 | 0 | 1,000 | The daily template allowance is shared by an account and its projects. Automations on a plan without webhooks still run on customer messages, keywords and buttons; they cannot be started by your system or call it. When an account leaves the Business plan, its webhook endpoints, connections and actions are kept and can still be edited, switched off or deleted, but nothing is sent to or called through them, and flow trigger URLs answer `403 not_in_plan`. ## Webhooks [#webhooks] | Limit | Value | | ----------------------------------------- | ------------------------------------------- | | Endpoints per account | 10 | | Endpoint URL length | 500 characters | | Endpoint name length | 80 characters | | Answer within | 10 s | | Attempts per delivery | 8 | | Retries after | 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h | | Endpoint switched off after | 100 failures in a row, or 3 days of failing | | Largest event body | 64 KB | | Message text in an event | 4,096 characters | | Your answer kept in the delivery log | First 2 KB | | Delivery log kept | 30 days | | Tests and resends | 10 a minute per account | | Signature tolerance to allow | 5 min | | Old secret keeps signing after a rotation | 24 h | | `apiVersion` | `2026-10` | ## Flow triggers [#flow-triggers] | Limit | Value | | ------------------------------------------------- | ----------------- | | Flow trigger URLs per account | 20 | | Request body | 64 KB | | Requests to one URL | 60 a minute | | Requests to all of an account's URLs | 1,000 an hour | | `Idempotency-Key` | 200 characters | | Templates automations may send one customer | 3 in any 24 hours | | Templates automations may send per day (Business) | 1,000 | ## API connections, HTTP steps and AI actions [#api-connections-http-steps-and-ai-actions] | Limit | Value | | -------------------------------------------------------------- | --------------------- | | API connections per account | 10 | | Timeout per call | 1–15 s (default 10 s) | | Calls in one go (between two customer messages) | 3 | | Time for all calls in one go, retries included | 30 s | | Calls per account per day (UTC), flows and AI actions together | 10,000 | | Request body, once filled in | 64 KB | | Response read | 256 KB | | AI actions per account | 20 | | AI action calls in one assistant reply | 3 | | AI action calls per customer per day (UTC) | 60 | See also [Rate limits](/get-started/rate-limits/). # Rate limits Source: https://docs.revoplyai.com/get-started/rate-limits/ > How many requests flow trigger URLs accept, how the limits answer, and how to retry. The limits below apply to [flow trigger](/flow-triggers/) URLs (`POST /hooks/{token}`). Limits for the REST API will be published with it. | Limit | Counted per | Answer | | -------------------------------------------- | -------------------------- | --------------------------------------------- | | 60 requests a minute | flow trigger URL | `429`, `{"status": "rate_limited"}` | | 1,000 requests an hour | account, all URLs together | `429`, `{"status": "rate_limited"}` | | 120 requests a minute | client IP address | `429`, plain text `Too many requests` | | Templates automations may send per day | account and its projects | `429`, `{"status": "template_limit_reached"}` | | 3 templates from automations in any 24 hours | customer | `429`, `{"status": "customer_limit_reached"}` | The minute and hour windows are fixed: they reset at the start of each clock minute and hour. The per-address limit is a sliding one-minute window that exists to slow down guessing of URLs; a single integration stays well below it. Every request to a valid URL counts towards the per-URL and per-account limits, including requests refused afterwards for another reason (a body that is not JSON, a flow that is switched off) and repeats of an `Idempotency-Key` already used. Requests refused for their `Content-Type` or their size are not counted. The two template limits are checked before a contact or conversation is created, and runs that are queued but have not sent their template yet count towards them. ## When our counters are unavailable [#when-our-counters-are-unavailable] If we cannot count requests, flow trigger URLs refuse the request with `503 service_unavailable` rather than accept it unlimited: each accepted request can end in a paid WhatsApp template. ## Retrying [#retrying] * Retry `429` and `503` with exponential backoff, starting at about a minute. * Send the same `Idempotency-Key` on every retry of one event, so a request that was accepted but whose answer you did not receive starts nothing new. See [Idempotency](/get-started/idempotency/). * Do not retry other `4xx` answers unchanged: they fail the same way every time. The full list is in [Responses and errors](/flow-triggers/responses-and-errors/). # Security Source: https://docs.revoplyai.com/get-started/security/ > Verify what we send you, keep secrets on your server, and what to do when a secret or URL leaks. Three kinds of secret connect RevoplyAI and your systems. Each is shown once and belongs on your server only: never in a browser, a mobile app, a repository or a support email. | Secret | Looks like | Proves | If it leaks | | ------------------------- | --------------------- | --------------------------------------- | ----------------------------------------------------------- | | Webhook signing secret | `whsec_…` | A request to your endpoint came from us | Rotate it | | Flow trigger URL | `…/hooks/rvh_…` | Anyone holding it may start that flow | Replace the URL | | API connection credential | Your own token or key | A call to your API came from us | Revoke it in your system, then replace it in the connection | Only an Owner or an Admin can see, create, rotate or replace them. ## Requests we send you [#requests-we-send-you] * **Verify every signature** before trusting a body; see [Signatures](/webhooks/signatures/) and the [verification guide](/guides/verify-webhook-signatures/). Refuse a request signed more than 5 minutes ago, which stops a captured request being replayed later. * **Do not rely on our IP addresses.** We do not publish a fixed set of addresses our requests come from. Authenticate by signature for webhooks, and by the credential in the connection for calls to your API. * **HTTPS only.** Webhook endpoints and API connections must use `https://` and resolve to public addresses. We check the address again at every connection. * **We do not follow redirects.** A `3xx` answer is a failed delivery or call. Give us the final URL. ## Rotating a webhook secret [#rotating-a-webhook-secret] Rotating (**Integrations → Webhooks → Rotate**) shows a new secret once. For the next 24 hours every delivery is signed with both the old and the new secret, as two `v1` values, so your receiver keeps accepting deliveries while you deploy the new one. After a leak, rotate twice: the second rotation retires the leaked secret at once, because only the two most recent secrets ever sign. ## Replacing a flow trigger URL [#replacing-a-flow-trigger-url] **Replace the URL** in the flow's trigger shows a new URL once. The old one stops working at once and answers `404 not_found`. Update every system that sends to it. ## API connection credentials [#api-connection-credentials] A connection's token, key or password is stored encrypted and never shown again; the dashboard shows its last four characters at most. It is added to each request after the request is built, so flow variables, customer messages and the assistant never see it, and it is removed from any response text before a flow or the assistant reads it. See [Requests we send](/connect-your-systems/requests-we-send/). ## The website widget [#the-website-widget] The widget's channel id is public by design: it sits in your page's source. Set **Allowed domains** on the channel so other sites cannot load it. See [Security and CSP](/widget/security-and-csp/). ## Reporting a vulnerability [#reporting-a-vulnerability] Email [support@revoplyai.com](mailto:support@revoplyai.com) with the details. Do not include secrets or customers' data. # Testing safely Source: https://docs.revoplyai.com/get-started/testing-safely/ > There is no sandbox. How to test webhooks with sample events, reach a local receiver, and keep test flows away from real customers. There is no sandbox and no test mode. Every flow that runs sends real WhatsApp messages, and every template it sends is billed by Meta. Test with sample events, your own phone number, and a project set aside for testing. ## Webhooks: send test events [#webhooks-send-test-events] On **Integrations → Webhooks**, **Send test** posts a sample of any event to an endpoint, signed with its current secret, and shows what your endpoint answered. * Choose **Ping** for a request with no customer data, or any event type for that event's documented example. * A test carries `"test": true` in the envelope; real events carry `"test": false`. Drop tests before they reach your CRM. * The endpoint does not have to be subscribed to the event, or switched on. * A test is not retried, and a failed test does not count towards switching the endpoint off. * Tests and resends are limited to 10 a minute per account. See [Testing webhooks](/webhooks/testing/). ## Reaching a receiver on your computer [#reaching-a-receiver-on-your-computer] We only deliver to public `https://` addresses, so `localhost` cannot receive events. Run a tunnel, such as `ngrok http 3000`, and add the HTTPS address it prints as an endpoint. Remove the endpoint when you are done: tunnel addresses are often reused by someone else later. ## Flow triggers: test with your own number [#flow-triggers-test-with-your-own-number] A flow trigger URL has no dry run: an accepted request sends the flow's template to the number in the body. 1. Build the flow and create its URL, but leave it unpublished. 2. Send one request with your own phone number. It is answered `409 flow_off`, and the builder keeps the body so you can map its fields with a tap. 3. Publish the flow, then send a request with your own number again, with a new `Idempotency-Key`. ## A project for testing [#a-project-for-testing] A [project](/get-started/introduction/#concepts) is a separate company in your account, with its own channels, contacts, flows and webhook endpoints, and its own `company.id` in events. Connect a test number to a project and build test flows there, so they can never reach your customers. Templates its automations send count against the daily allowance shared with the rest of your account. # Versioning Source: https://docs.revoplyai.com/get-started/versioning/ > What we may change without notice, what counts as breaking, and how webhook payload versions and the REST API version work. ## Changes that are not breaking [#changes-that-are-not-breaking] These can happen at any time, without a new version. Write your integration so it tolerates them: * new fields in an event, a response or an object inside them; * new event types (you receive only the types an endpoint subscribes to); * new values in a vocabulary: `channelType`, a handover `reason`, a flow run's `endReason`, a flow trigger `status`; * new optional request headers or fields; * different wording in `detail` and other text meant for people. Branch on codes, never on text. ## Webhook payload versions [#webhook-payload-versions] Every event carries `apiVersion`, the shape of its `data`. The current version is `2026-10`. * A change a receiver could misread (a field removed, renamed or given another meaning) gets a new dated version. * A new version is opt-in per endpoint: an endpoint keeps receiving the version it has until you move it. * An old version keeps being sent for at least six months after the new one is published, and its retirement is announced. ## The REST API [#the-rest-api] The REST API, when it is published, is versioned in its path: `https://api.revoplyai.com/v1`. * A breaking change is released as a new major version (`/v2`), never into `/v1`. * A new major version is announced at least 90 days in advance, and the previous version keeps working for at least six months after the new one is released. * Responses from a version being retired carry `Deprecation` and `Sunset` headers. ## Notice [#notice] Deprecations and new versions are announced in the [changelog](/resources/changelog/) and emailed to account owners whose integrations are affected. # WhatsApp messaging rules Source: https://docs.revoplyai.com/get-started/whatsapp-messaging-rules/ > The 24-hour service window, when a template is required, how Meta bills templates, and what a QR-linked number cannot do. WhatsApp decides what a business may send and when. These rules come from Meta and apply to every message sent from your number, whether the assistant, your team, a flow or your system sends it. ## The 24-hour service window [#the-24-hour-service-window] When a customer writes to your WhatsApp Business number, a 24-hour window opens. Until it closes, you may send any message: text, media, buttons. Each new message from the customer restarts the 24 hours. Outside the window, and to a customer who has never written, you may only send a **template**. When the REST API is published, a message that is not a template sent outside the window will be refused with `outside_service_window`. ## Templates [#templates] A template is a message you submit to Meta for approval in advance, with placeholders for values such as a name or an order number. Only templates with the status `APPROVED` can be sent. Meta classifies each one as `MARKETING`, `UTILITY` or `AUTHENTICATION`. * A flow started by your system ([flow triggers](/flow-triggers/)) must send a template before anything else: nobody has written, so no window is open. * Templates sent by automations count against a daily allowance shared by the account and its projects, and at most 3 reach one customer in any 24 hours. See [Plans and limits](/get-started/plans-and-limits/). ## Who pays for templates [#who-pays-for-templates] Meta charges for every template it delivers, at a rate that depends on the template's category and the recipient's country code. The charge goes to the payment method on your own WhatsApp Business account, directly from Meta; it is not part of your RevoplyAI bill. ## WhatsApp QR numbers [#whatsapp-qr-numbers] A number linked by scanning a QR code (`whatsapp_qr`) is not a WhatsApp Business Platform number: * it cannot send templates, so it can never start a conversation or reach a customer outside a conversation they started; * flow triggers cannot use it. See [Channels and capabilities](/get-started/channels-and-capabilities/) for what each channel can send. ## Opt-outs and quality [#opt-outs-and-quality] Customers who reply `STOP` are opted out; see [Opt-out and compliance](/get-started/opt-out-and-compliance/). Customers who block or report your number lower its quality rating with Meta, which can limit how many customers it may message a day. Send templates only to customers who expect them. # Hand over to a human Source: https://docs.revoplyai.com/guides/hand-over-to-a-human/ > Open a ticket in your helpdesk when the assistant hands a conversation to your team, using the conversation.handover event. When the assistant cannot or should not carry on (the customer asks for a person, is upset, or asks something only your team can do) it hands the conversation to your team. The [`conversation.handover`](/webhooks/events/conversation-handover/) event tells your systems at that moment, so you can open a ticket, page someone or post to a channel. ## 1. Choose the events [#1-choose-the-events] Add an endpoint under **Integrations → Webhooks**, or edit one, and choose **Handed over to your team**. Add **Conversation assigned** and **Conversation resolved** to follow the conversation afterwards. Build the receiver as in [Receive messages with webhooks](/guides/receive-messages-with-webhooks/). ## 2. Read the event [#2-read-the-event] It fires when the conversation is handed over: the customer asked for a person, the assistant was missing information, a flow reached a handover step, a lead qualified for a handover, or loop protection stepped in. It fires once per handover: a customer who asks twice while the handover is open raises it once. | Field | Holds | | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `data.reason` | Why, as a stable code: `customer_asked_for_person`, `missing_information`, `needs_staff_action`, `customer_upset`, `qualified_lead`, `flow_handover`, `message_burst`, `unsupported_media`… Treat an unknown code as "other". | | `data.reasonText` | The assistant's own words, when it gave any. For people; often Arabic. | | `data.conversation` | The conversation's `id`, `channelId` and `channelType` | | `data.contact` | The customer's `id`, `name`, `phone` and `whatsAppId`, where the channel knows them | ## 3. Open a ticket [#3-open-a-ticket] Turn the event into a ticket, keyed on the event `id` so a repeated delivery opens one ticket: ```js export function ticketFromHandover(event) { if (event.type !== 'conversation.handover' || event.test) return null; const { conversation, contact, reason, reasonText } = event.data; return { // One ticket per event, however often the event is delivered. externalId: event.id, subject: `Handover (${reason}): ${contact?.name ?? contact?.phone ?? 'a customer'}`, // The assistant's own words, when it gave any; often Arabic. description: reasonText ?? '', priority: reason === 'customer_upset' ? 'high' : 'normal', channelType: conversation.channelType, conversationId: conversation.id, phone: contact?.phone ?? null, openedAt: event.createdAt, }; } ``` Answer `200` first, then call your helpdesk from a queue: a helpdesk that is slow to answer must not make the delivery time out. ## 4. Follow the conversation [#4-follow-the-conversation] * [`conversation.assigned`](/webhooks/events/conversation-assigned/) fires when a team member assigns, reassigns or unassigns it; `data.assignee` is `null` when unassigned. * [`conversation.resolved`](/webhooks/events/conversation-resolved/) fires when a team member resolves or closes it. Close the ticket then. A bulk close sends only the conversation `id`, so key tickets on it. Your team answers the customer from the RevoplyAI inbox. # Receive messages with webhooks Source: https://docs.revoplyai.com/guides/receive-messages-with-webhooks/ > Build a receiver that gets every customer message as it arrives, verifies it, deduplicates it and answers in time. This guide builds an endpoint that receives [`message.received`](/webhooks/events/message-received/) every time a customer writes to you on any channel, and logs who wrote what. You need the Business plan and an Owner or Admin login. ## 1. Write the receiver [#1-write-the-receiver] The receiver must do four things, in this order: verify the signature against the raw body, skip an event id it has already handled, answer `2xx` within 10 seconds, then do the work. Node.js (Express) Python (Flask) PHP (Laravel) C# (ASP.NET Core) ```js // A RevoplyAI webhook receiver on Express 4 or 5. // // npm install express // REVOPLY_WEBHOOK_SECRET=whsec_… node express.mjs import express from 'express'; import { verifyRevoplySignature } from '../verify.node.mjs'; const secret = process.env.REVOPLY_WEBHOOK_SECRET; const app = express(); // Stands in for your database: remember the event ids you have handled. const handled = new Set(); // #region route // express.raw() on this route, registered before any app.use(express.json()): the signature // covers the exact bytes we sent, and a parsed-then-re-serialised body is not those bytes. app.post('/webhooks/revoply', express.raw({ type: 'application/json' }), (req, res) => { if (!verifyRevoplySignature(req.body, req.get('X-Revoply-Signature'), secret)) { return res.status(400).send('Invalid signature'); } const event = JSON.parse(req.body.toString('utf8')); // The same event can arrive more than once: acknowledge a repeat and do nothing. if (handled.has(event.id)) return res.sendStatus(200); handled.add(event.id); // Answer within 10 seconds, then do the work. res.sendStatus(200); setImmediate(() => handleEvent(event)); }); // #endregion // #region handle function handleEvent(event) { // "Send test" posts sample data about nobody real, marked "test": true. if (event.test) { console.log(`test ${event.type} received`); return; } switch (event.type) { case 'message.received': { const { conversation, contact, message } = event.data; console.log( `${contact?.name ?? contact?.phone ?? 'A visitor'} wrote on ${conversation.channelType}:`, ); console.log(message.text ?? `[${message.type}]`); break; } default: // A type you did not expect is still acknowledged; new events may be added. console.log(`ignored ${event.type}`); } } // #endregion // The rest of the app may parse JSON as usual, registered after the webhook route. app.use(express.json()); app.listen(3000, () => console.log('Listening on port 3000, path /webhooks/revoply')); ``` ```python @app.post("/webhooks/revoply") def revoply_webhook(): # get_data() is the body as it arrived. Never verify json.dumps(request.json): # re-serialised JSON is not the bytes we signed. raw_body = request.get_data() if not verify_revoply_signature(raw_body, request.headers.get("X-Revoply-Signature"), SECRET): return "Invalid signature", 400 event = json.loads(raw_body) if event["id"] in handled: return "", 200 handled.add(event["id"]) # Answer within 10 seconds; hand slow work to a queue. print(event["type"], "(test)" if event["test"] else "") return "", 200 ``` ```php public function __invoke(Request $request) { // getContent() is the body as it arrived. Never verify json_encode($request->all()): // re-encoded JSON is not the bytes we signed. $rawBody = $request->getContent(); if (!revoply_verify_signature($rawBody, $request->header('X-Revoply-Signature'), (string) config('services.revoply.webhook_secret'))) { return response('Invalid signature', 400); } $event = json_decode($rawBody, true); // The same event can arrive more than once: acknowledge a repeat and do nothing. if (!Cache::add('revoply-event:' . $event['id'], true, now()->addDays(30))) { return response('', 200); } // Answer within 10 seconds; queue slow work (dispatch a job) instead of doing it here. logger()->info('RevoplyAI event', ['type' => $event['type'], 'test' => $event['test']]); return response('', 200); } ``` ```csharp app.MapPost("/webhooks/revoply", async (HttpRequest request) => { // Verify the bytes that arrived. If anything reads the body before this handler // ([FromBody] binding, a logging middleware), buffer it and rewind, or there is // nothing left to verify. request.EnableBuffering(); using var copy = new MemoryStream(); await request.Body.CopyToAsync(copy); request.Body.Position = 0; var rawBody = copy.ToArray(); string? header = request.Headers["X-Revoply-Signature"]; if (!RevoplySignature.Verify(rawBody, header, [secret])) { return Results.BadRequest(); } using var envelope = JsonDocument.Parse(rawBody); var id = envelope.RootElement.GetProperty("id").GetString()!; var type = envelope.RootElement.GetProperty("type").GetString(); var isTest = envelope.RootElement.GetProperty("test").GetBoolean(); // The same event can arrive more than once: acknowledge a repeat and do nothing. if (!Handled.TryAdd(id, true)) { return Results.Ok(); } // Answer within 10 seconds; queue the real work instead of doing it here. Console.WriteLine($"{id} {type}{(isTest ? " (test)" : "")}"); return Results.Ok(); }); ``` Each one imports the verifier from [Signatures](/webhooks/signatures/#verifiers). The id set is a stand-in: in production, record handled ids in your database, in the same transaction as the work they trigger. ## 2. Make it reachable [#2-make-it-reachable] We deliver only to public `https://` addresses. Deploy the receiver, or expose it from your computer with a tunnel such as `ngrok http 3000`; see [Testing safely](/get-started/testing-safely/#reaching-a-receiver-on-your-computer). ## 3. Add the endpoint [#3-add-the-endpoint] 1. Open **Integrations → Webhooks → Add endpoint**. 2. Enter your receiver's URL, such as `https://example.com/webhooks/revoply`, and choose **Message received**. 3. Save, and copy the signing secret it shows once into your receiver's configuration as `REVOPLY_WEBHOOK_SECRET`. ## 4. Send a test [#4-send-a-test] Choose **Send test** on the endpoint, pick **Message received**, and send. Your receiver logs `test message.received received`, and the dashboard shows the `200` it answered. The example is marked `"test": true`; the receiver above skips such events before doing any work. If the test fails, the dashboard shows why; a `400` usually means the signature check failed, see [Verify webhook signatures](/guides/verify-webhook-signatures/#when-verification-fails). ## 5. Read the message [#5-read-the-message] Real events carry `"test": false`. The fields you will use most: | Field | Holds | | ------------------------------- | --------------------------------------------------------------------------- | | `data.conversation.id` | The conversation; opaque, store it as it is | | `data.conversation.channelType` | `whatsapp`, `instagram`, `web_widget`… treat unknown values as "other" | | `data.contact.name` | The customer's name, when known, or `null` | | `data.contact.phone` | E.164 or `null`; see [Identifiers](/get-started/identifiers/) | | `data.message.type` | `text`, `image`, `audio`, `document`… | | `data.message.text` | The text, or a media message's caption; at most 4,096 characters, or `null` | The event fires once per customer message, on every channel, whoever is handling the conversation. It does not fire for messages your business sends: the assistant's replies, flows, or your team's. ## Before going live [#before-going-live] * Keep the secret in your server's configuration, never in the code. * Record handled event ids durably, for at least as long as a delivery can be retried or resent (30 days covers both). * Move slow work (CRM calls, emails) to a queue so every answer is fast. * Watch the endpoint's delivery log for failures after deploys. An endpoint that fails 100 times in a row, or for 3 days, is switched off. # Start a flow from your system Source: https://docs.revoplyai.com/guides/start-a-flow-from-your-system/ > Send a WhatsApp template when an order ships, by posting your order JSON to a flow trigger URL, with safe retries. This guide sends a customer a WhatsApp message when your store marks their order as shipped, and lets them reply in the same conversation. You need the Business plan, a connected WhatsApp Business number, an approved template, and an Owner or Admin login. ## 1. Build the flow [#1-build-the-flow] 1. In **Automations**, create a flow and choose the trigger **Your system sends an event**. 2. Choose the WhatsApp number to send from. 3. Add a template step first, such as your approved `order_shipped` template. A flow started by your system must send a template before anything else. Add whatever follows: a question, a handover to your team, an end. 4. **Create the URL** and copy it into your store's server configuration as `REVOPLY_HOOK_URL`. It is shown in full only once. Treat it as a password. Leave the flow unpublished for now. ## 2. Send a sample request [#2-send-a-sample-request] Send one request with your own phone number in it, in the shape your store already uses: ```bash curl --silent --show-error "$REVOPLY_HOOK_URL" \ --header 'Content-Type: application/json' \ --header 'Idempotency-Key: order-10482-shipped' \ --data '{ "order": { "id": "10482", "status": "shipped" }, "customer": { "name": "نورة العتيبي", "phone": "+966501234567" } }' \ --write-out '\nHTTP %{http_code}\n' ``` It is answered `409` with `"status": "flow_off"`, because the flow is not published, and the builder keeps the body. Back in the trigger settings, choose **Refresh**, then tap `customer.phone` for the phone number, `customer.name` for the name, and `order.id` as a value to keep, say as `order_id`, to use in the template. ## 3. Publish and send for real [#3-publish-and-send-for-real] Publish the flow and switch it on. Now call the URL from your code when an order ships. Use an `Idempotency-Key` built from the event itself, such as `order-10482-shipped`, and send the same key on every retry of that event: Node.js Python ```js // Starts a RevoplyAI flow from your system (Node.js 18+, no packages). // // REVOPLY_HOOK_URL=https://api.revoplyai.com/hooks/rvh_… node start-flow.node.mjs // // The hook URL is the credential: keep it in your server's configuration, never in a browser. import { fileURLToPath } from 'node:url'; // #region start /** The outcomes worth trying again later with the same Idempotency-Key. */ const RETRYABLE = new Set([ 'channel_unavailable', 'rate_limited', 'template_limit_reached', 'customer_limit_reached', 'service_unavailable', ]); export async function startFlow(hookUrl, payload, idempotencyKey, { fetchImpl = fetch } = {}) { const response = await fetchImpl(hookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey }, body: JSON.stringify(payload), }); // Every answer is { id, status, detail }, except a 429 from the per-address limit in front // of the hooks, which is plain text. const outcome = await response.json().catch(() => ({ id: null, status: null, detail: null })); const status = outcome.status ?? (response.status === 429 ? 'rate_limited' : 'unexpected'); return { ok: status === 'accepted' || status === 'duplicate', retry: RETRYABLE.has(status) || response.status >= 500, httpStatus: response.status, runId: outcome.id, status, detail: outcome.detail, }; } // #endregion // #region example const order = { order: { id: '10482', status: 'shipped' }, customer: { name: 'نورة العتيبي', phone: '+966501234567' }, }; async function main() { // One key per business event, reused on every retry of it: a retry that reaches us twice // starts the flow once. const key = `order-${order.order.id}-${order.order.status}`; for (let attempt = 1; attempt <= 4; attempt++) { const result = await startFlow(process.env.REVOPLY_HOOK_URL, order, key); console.log( `${result.httpStatus} ${result.status}${result.detail ? `: ${result.detail}` : ''}`, ); if (result.ok) { console.log(`run ${result.runId}`); return 0; } if (!result.retry) return 1; await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 15_000)); } return 1; } // #endregion if (process.argv[1] === fileURLToPath(import.meta.url)) { process.exitCode = await main(); } ``` ```python """Starts a RevoplyAI flow from your system (Python 3.9+, standard library). REVOPLY_HOOK_URL=https://api.revoplyai.com/hooks/rvh_... python start_flow.py The hook URL is the credential: keep it in your server's configuration, never in a browser. """ import json import os import sys import time import urllib.error import urllib.request # region start # The outcomes worth trying again later with the same Idempotency-Key. RETRYABLE = { "channel_unavailable", "rate_limited", "template_limit_reached", "customer_limit_reached", "service_unavailable", } def start_flow(hook_url: str, payload: dict, idempotency_key: str) -> dict: request = urllib.request.Request( hook_url, data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), headers={"Content-Type": "application/json", "Idempotency-Key": idempotency_key}, method="POST", ) try: with urllib.request.urlopen(request, timeout=30) as response: http_status, body = response.status, response.read() except urllib.error.HTTPError as error: # 4xx and 5xx still carry { id, status, detail } http_status, body = error.code, error.read() try: outcome = json.loads(body) except ValueError: # the per-address limit in front of the hooks answers 429 as plain text outcome = {"id": None, "status": "rate_limited" if http_status == 429 else "unexpected", "detail": None} status = outcome.get("status") return { "ok": status in ("accepted", "duplicate"), "retry": status in RETRYABLE or http_status >= 500, "http_status": http_status, "run_id": outcome.get("id"), "status": status, "detail": outcome.get("detail"), } # endregion # region example def main() -> int: order = { "order": {"id": "10482", "status": "shipped"}, "customer": {"name": "نورة العتيبي", "phone": "+966501234567"}, } # One key per business event, reused on every retry of it. key = f"order-{order['order']['id']}-{order['order']['status']}" for attempt in range(1, 5): result = start_flow(os.environ["REVOPLY_HOOK_URL"], order, key) print(result["http_status"], result["status"], result["detail"] or "") if result["ok"]: print("run", result["run_id"]) return 0 if not result["retry"]: return 1 time.sleep(2**attempt * 15) return 1 # endregion if __name__ == "__main__": sys.exit(main()) ``` A `202` with `"status": "accepted"` means the run is queued: the template goes out in a moment. A `200` with `"status": "duplicate"` means this order's event already started a run; nothing new happens. ## 4. Handle what comes back [#4-handle-what-comes-back] * Retry `rate_limited`, `template_limit_reached`, `customer_limit_reached`, `channel_unavailable` and `service_unavailable` later, with the same key. The samples above back off and try up to four times. * Fix, do not retry, everything else. `invalid_phone` means the phone path found nothing readable; send numbers with their country code. * Never retry `opted_out`. The full list is in [Responses and errors](/flow-triggers/responses-and-errors/). ## 5. Learn how each run ended [#5-learn-how-each-run-ended] Keep the `id` from the `202`. Subscribe a [webhook](/webhooks/) endpoint to **Automation completed** and **Automation failed**: their `data.run.id` is that id, and `data.run.variables` holds what the flow collected, such as the customer's answer. ## Before going live [#before-going-live] * Test with your own number; there is no test mode. * Each accepted request sends a template Meta bills for, and counts against the daily allowance for automated templates and the limit of 3 per customer in 24 hours. * If the URL may have leaked, **Replace the URL**: the old one stops working at once. # Verify webhook signatures Source: https://docs.revoplyai.com/guides/verify-webhook-signatures/ > Verify X-Revoply-Signature in Node.js, Python, PHP or C#, get the raw body in Express, Flask, Laravel and ASP.NET, and debug failures. Every webhook request carries `X-Revoply-Signature: t=…,v1=…`. Verifying it proves the request came from us, was not changed, and is recent. The rules are on [Signatures](/webhooks/signatures/); this guide puts them into code. ## 1. Add a verifier [#1-add-a-verifier] Copy the verifier for your language into your project. Each is a single function with no dependencies, and each passes the published [test vectors](/webhooks/signatures/#test-vectors). Node.js Python PHP C# ```js // Verifies the X-Revoply-Signature header on a RevoplyAI webhook (Node.js 18+, no packages). // // X-Revoply-Signature: t=1790847005,v1=3f96ce90…[,v1=… while a rotated secret still signs] // // v1 is the hex HMAC-SHA256 of "{t}.{raw body}", keyed with the whole secret, whsec_ included. // Tested against openapi/webhook-signature-vectors.json by samples/webhooks/test/. import { createHmac, timingSafeEqual } from 'node:crypto'; /** How far the signed time may be from your clock before the request is refused. */ export const TOLERANCE_SECONDS = 300; /** * @param {Buffer | string} rawBody The body exactly as it arrived, before any JSON parsing. * @param {string | undefined} header The X-Revoply-Signature header. * @param {string | string[]} secrets Your whsec_… secret. While you switch to a rotated * secret, pass both. * @param {{ now?: number, toleranceSeconds?: number }} [options] `now` in Unix seconds. * @returns {boolean} */ export function verifyRevoplySignature(rawBody, header, secrets, options = {}) { if (typeof header !== 'string' || header.length === 0) return false; let timestamp; const signatures = []; for (const part of header.split(',')) { const index = part.indexOf('='); if (index === -1) continue; const key = part.slice(0, index).trim(); const value = part.slice(index + 1).trim(); if (key === 't' && /^\d+$/.test(value)) timestamp = Number(value); else if (key === 'v1') signatures.push(value); } if (timestamp === undefined || signatures.length === 0) return false; const now = options.now ?? Math.floor(Date.now() / 1000); const tolerance = options.toleranceSeconds ?? TOLERANCE_SECONDS; if (Math.abs(now - timestamp) > tolerance) return false; const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, 'utf8'); for (const secret of [secrets].flat()) { if (!secret) continue; const expected = Buffer.from( createHmac('sha256', secret).update(`${timestamp}.`).update(body).digest('hex'), ); for (const signature of signatures) { const given = Buffer.from(signature); if (given.length === expected.length && timingSafeEqual(given, expected)) return true; } } return false; } ``` ```python """Verifies the X-Revoply-Signature header on a RevoplyAI webhook (Python 3.9+, standard library). X-Revoply-Signature: t=1790847005,v1=3f96ce90...[,v1=... while a rotated secret still signs] v1 is the hex HMAC-SHA256 of "{t}.{raw body}", keyed with the whole secret, whsec_ included. Tested against openapi/webhook-signature-vectors.json by samples/webhooks/test/. """ import hashlib import hmac import time from typing import Iterable, Optional, Union # How far the signed time may be from your clock before the request is refused. TOLERANCE_SECONDS = 300 def verify_revoply_signature( raw_body: Union[bytes, str], header: Optional[str], secrets: Union[str, Iterable[str]], now: Optional[int] = None, tolerance_seconds: int = TOLERANCE_SECONDS, ) -> bool: """raw_body is the body exactly as it arrived. While you switch to a rotated secret, pass both secrets.""" if not header: return False timestamp = None signatures = [] for part in header.split(","): key, sep, value = part.partition("=") if not sep: continue key, value = key.strip(), value.strip() if key == "t" and value.isdigit(): timestamp = int(value) elif key == "v1": signatures.append(value) if timestamp is None or not signatures: return False current = int(time.time()) if now is None else now if abs(current - timestamp) > tolerance_seconds: return False body = raw_body if isinstance(raw_body, bytes) else raw_body.encode("utf-8") for secret in [secrets] if isinstance(secrets, str) else secrets: if not secret: continue expected = hmac.new( secret.encode("utf-8"), f"{timestamp}.".encode("ascii") + body, hashlib.sha256 ).hexdigest() if any(hmac.compare_digest(expected, signature) for signature in signatures): return True return False ``` ```php $toleranceSeconds) { return false; } foreach ((array) $secrets as $secret) { if ($secret === '') { continue; } $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); foreach ($signatures as $signature) { if (hash_equals($expected, $signature)) { return true; } } } return false; } ``` ```csharp // Verifies the X-Revoply-Signature header on a RevoplyAI webhook (.NET 8+, no packages). // // X-Revoply-Signature: t=1790847005,v1=3f96ce90…[,v1=… while a rotated secret still signs] // // v1 is the hex HMAC-SHA256 of "{t}.{raw body}", keyed with the whole secret, whsec_ included. // Tested against openapi/webhook-signature-vectors.json by samples/webhooks/test/. using System.Globalization; using System.Security.Cryptography; using System.Text; public static class RevoplySignature { /// How far the signed time may be from your clock before the request is refused. public const int ToleranceSeconds = 300; /// The body exactly as it arrived, before any JSON parsing. /// The X-Revoply-Signature header. /// Your whsec_… secret. While you switch to a rotated secret, pass both. /// Unix seconds; the current time when null. public static bool Verify( byte[] rawBody, string? header, IEnumerable secrets, long? now = null, int toleranceSeconds = ToleranceSeconds) { if (string.IsNullOrEmpty(header)) { return false; } long? timestamp = null; var signatures = new List(); foreach (var part in header.Split(',')) { var pair = part.Split('=', 2); if (pair.Length != 2) { continue; } var key = pair[0].Trim(); var value = pair[1].Trim(); if (key == "t" && long.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var t)) { timestamp = t; } else if (key == "v1") { signatures.Add(value); } } if (timestamp is not { } signedAt || signatures.Count == 0) { return false; } var current = now ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds(); if (Math.Abs(current - signedAt) > toleranceSeconds) { return false; } var prefix = Encoding.ASCII.GetBytes(signedAt.ToString(CultureInfo.InvariantCulture) + "."); foreach (var secret in secrets) { if (string.IsNullOrEmpty(secret)) { continue; } using var hmac = IncrementalHash.CreateHMAC(HashAlgorithmName.SHA256, Encoding.UTF8.GetBytes(secret)); hmac.AppendData(prefix); hmac.AppendData(rawBody); var expected = Encoding.ASCII.GetBytes(Convert.ToHexString(hmac.GetHashAndReset()).ToLowerInvariant()); foreach (var signature in signatures) { if (CryptographicOperations.FixedTimeEquals(expected, Encoding.ASCII.GetBytes(signature))) { return true; } } } return false; } } ``` ## 2. Get the raw body [#get-the-raw-body] The signature covers the body exactly as we sent it. Most frameworks parse JSON before your handler runs, and a body that was parsed and serialised again differs from ours in key order, spacing or the escaping of Arabic text, so it never verifies. Read the raw bytes instead. ### Express [#express] Use `express.raw()` on the webhook route, registered before any global `app.use(express.json())`. `req.body` is then a `Buffer`. ```js // express.raw() on this route, registered before any app.use(express.json()): the signature // covers the exact bytes we sent, and a parsed-then-re-serialised body is not those bytes. app.post('/webhooks/revoply', express.raw({ type: 'application/json' }), (req, res) => { if (!verifyRevoplySignature(req.body, req.get('X-Revoply-Signature'), secret)) { return res.status(400).send('Invalid signature'); } const event = JSON.parse(req.body.toString('utf8')); // The same event can arrive more than once: acknowledge a repeat and do nothing. if (handled.has(event.id)) return res.sendStatus(200); handled.add(event.id); // Answer within 10 seconds, then do the work. res.sendStatus(200); setImmediate(() => handleEvent(event)); }); ``` ### Flask [#flask] Use `request.get_data()`, never `json.dumps(request.json)`. ```python @app.post("/webhooks/revoply") def revoply_webhook(): # get_data() is the body as it arrived. Never verify json.dumps(request.json): # re-serialised JSON is not the bytes we signed. raw_body = request.get_data() if not verify_revoply_signature(raw_body, request.headers.get("X-Revoply-Signature"), SECRET): return "Invalid signature", 400 event = json.loads(raw_body) if event["id"] in handled: return "", 200 handled.add(event["id"]) # Answer within 10 seconds; hand slow work to a queue. print(event["type"], "(test)" if event["test"] else "") return "", 200 ``` ### Laravel [#laravel] Use `$request->getContent()`, never `json_encode($request->all())`. Put the route in `routes/api.php`, which has no CSRF check. ```php public function __invoke(Request $request) { // getContent() is the body as it arrived. Never verify json_encode($request->all()): // re-encoded JSON is not the bytes we signed. $rawBody = $request->getContent(); if (!revoply_verify_signature($rawBody, $request->header('X-Revoply-Signature'), (string) config('services.revoply.webhook_secret'))) { return response('Invalid signature', 400); } $event = json_decode($rawBody, true); // The same event can arrive more than once: acknowledge a repeat and do nothing. if (!Cache::add('revoply-event:' . $event['id'], true, now()->addDays(30))) { return response('', 200); } // Answer within 10 seconds; queue slow work (dispatch a job) instead of doing it here. logger()->info('RevoplyAI event', ['type' => $event['type'], 'test' => $event['test']]); return response('', 200); } ``` In plain PHP, read `php://input`; `$_POST` is empty for JSON bodies. ```php // php://input is the body as it arrived; $_POST is empty for JSON. $rawBody = file_get_contents('php://input'); if (!revoply_verify_signature($rawBody, $_SERVER['HTTP_X_REVOPLY_SIGNATURE'] ?? null, (string) getenv('REVOPLY_WEBHOOK_SECRET'))) { http_response_code(400); exit('Invalid signature'); } $event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR); // Deduplicate on $event['id'] in your database, then answer within 10 seconds. error_log(sprintf('RevoplyAI %s %s%s', $event['id'], $event['type'], $event['test'] ? ' (test)' : '')); http_response_code(200); ``` ### ASP.NET Core [#aspnet-core] Take `HttpRequest` rather than binding a model with `[FromBody]`, call `EnableBuffering()`, copy the body, then rewind it so later code can read it again. ```csharp app.MapPost("/webhooks/revoply", async (HttpRequest request) => { // Verify the bytes that arrived. If anything reads the body before this handler // ([FromBody] binding, a logging middleware), buffer it and rewind, or there is // nothing left to verify. request.EnableBuffering(); using var copy = new MemoryStream(); await request.Body.CopyToAsync(copy); request.Body.Position = 0; var rawBody = copy.ToArray(); string? header = request.Headers["X-Revoply-Signature"]; if (!RevoplySignature.Verify(rawBody, header, [secret])) { return Results.BadRequest(); } using var envelope = JsonDocument.Parse(rawBody); var id = envelope.RootElement.GetProperty("id").GetString()!; var type = envelope.RootElement.GetProperty("type").GetString(); var isTest = envelope.RootElement.GetProperty("test").GetBoolean(); // The same event can arrive more than once: acknowledge a repeat and do nothing. if (!Handled.TryAdd(id, true)) { return Results.Ok(); } // Answer within 10 seconds; queue the real work instead of doing it here. Console.WriteLine($"{id} {type}{(isTest ? " (test)" : "")}"); return Results.Ok(); }); ``` ## 3. Test it [#3-test-it] 1. Run your verifier against the vectors: for each entry in [`webhook-signature-vectors.json`](/openapi/webhook-signature-vectors.json), verify `header` against `body` with each secret in `verifyWith`, at the time `now`, and compare the result with `valid`. 2. Send a test from **Integrations → Webhooks → Send test**. The dashboard shows what your endpoint answered. 3. Change one character of the secret in your configuration and send another test: your receiver must answer `400`. ## When verification fails [#when-verification-fails] | Symptom | Cause | | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | Fails for every request | The body was parsed before verification; see step 2. | | Fails for every request | The key is not the whole secret. Use the string as shown, `whsec_` included, as UTF-8; do not base64-decode it. | | Fails for every request | The wrong endpoint's secret: each endpoint has its own. | | Fails only for messages with Arabic text | The body was decoded and re-encoded in another encoding. Hash the bytes as received. | | Fails every time, with a correct HMAC | The server's clock is more than 5 minutes off. Synchronise it. | | Fails after a rotation | The new secret is not deployed yet. Accept both secrets during the switch. | | Fails only behind one proxy | The proxy rewrites the body (compression, re-encoding). Verify before it, or pass the body through untouched. | Compare signatures in constant time, as the verifiers above do: `==` on strings leaks how much of a guess was right. # RevoplyAI developer docs Source: https://docs.revoplyai.com/ > Connect your own systems to RevoplyAI with signed webhooks, flow triggers, API connections and the website widget. Connect your own systems to RevoplyAI: receive signed events when customers write, start WhatsApp flows from your backend, let flows and the assistant call your API, and embed the chat widget on your site. Webhooks, flow triggers, API connections and AI actions are available on the Business plan. The concepts every page uses: companies, channels, contacts, conversations and flows. Events we post to your HTTPS endpoint, signed so you can verify them. Start a WhatsApp flow for a customer when something happens in your system. Let flows and the assistant look things up and act in your own API. Install the chat widget, drive it from JavaScript and listen to its events. The machine-readable contract: the flow trigger endpoint and every webhook event. ## Guides [#guides] * [Receive messages with webhooks](/guides/receive-messages-with-webhooks/) * [Verify webhook signatures](/guides/verify-webhook-signatures/) * [Start a flow from your system](/guides/start-a-flow-from-your-system/) * [Hand over to a human](/guides/hand-over-to-a-human/) # AI assistants Source: https://docs.revoplyai.com/resources/ai-assistants/ > Use these docs with ChatGPT, Claude and coding assistants through llms.txt, Markdown versions of every page and page actions. Every page of these docs is also available as plain Markdown, for AI assistants and coding agents. | Resource | What it is | | -------------------------------------- | --------------------------------------------------------------------------------------------------------- | | [`/llms.txt`](/llms.txt) | An index of every page, each linking to its Markdown version. | | [`/llms-full.txt`](/llms-full.txt) | Every page in one Markdown file. | | `//index.md` | The Markdown version of a page, such as [`/webhooks/signatures/index.md`](/webhooks/signatures/index.md). | | [`/openapi/v1.json`](/openapi/v1.json) | The OpenAPI specification. | The Markdown versions carry the same tables, code samples and examples as the pages, and the reference pages are rendered from the specification. ## Page actions [#page-actions] At the top of every page: * **Copy page** copies the page as Markdown, to paste into any assistant. * **View as Markdown** opens the page's Markdown version. * **Open in ChatGPT** and **Open in Claude** start a conversation that asks the assistant to read the page. ## Tips [#tips] * Give an agent the page for the task, not the whole site: `llms-full.txt` is long. * Point it at the verifiers under [Signatures](/webhooks/signatures/#verifiers) rather than asking it to write one; they pass the published test vectors. * Do not paste secrets into an assistant: signing secrets, flow trigger URLs or credentials. # Changelog Source: https://docs.revoplyai.com/resources/changelog/ > What changed in the RevoplyAI API, webhooks, flow triggers and website widget, newest first. New events, fields, versions and deprecations are announced here first. Follow the [RSS feed](/changelog.xml) to hear about them. ## Developer docs published: webhooks, flow triggers, widget [#2026-10-01-developer-docs-published] *1 October 2026* The first version of these docs covers what you can build on today: * [Webhooks](/webhooks/): the envelope, [signatures](/webhooks/signatures/) with verifiers in Node.js, Python, PHP and C#, retries, the delivery log, and a page for every [event](/webhooks/events/). * [Flow triggers](/flow-triggers/): starting a WhatsApp flow from your system, and every answer a trigger URL gives. * [API connections, HTTP steps and AI actions](/connect-your-systems/). * The [website widget](/widget/): installation, JavaScript API, events and CSP. # OpenAPI Source: https://docs.revoplyai.com/resources/openapi/ > Download the RevoplyAI OpenAPI specification and the webhook signature test vectors. * [OpenAPI 3.1](/openapi/v1.json): the canonical contract. Today it describes the [flow trigger](/flow-triggers/) endpoint (`POST /hooks/{token}`) and every [webhook event](/webhooks/events/) under `webhooks`. The REST API's operations are added when it is published. * [OpenAPI 3.0](/openapi/v1.openapi-3.0.json): derived from the 3.1 file for tools that do not read 3.1 yet. It cannot hold webhooks, so it has none. * [Webhook signature test vectors](/openapi/webhook-signature-vectors.json): signed requests to test a verifier against; see [Signatures](/webhooks/signatures/#test-vectors). The specification uses these extensions: | Extension | Meaning | | --------------------- | ------------------------------------------------------------------------- | | `x-revoply-plan` | The plan an operation needs, such as `Business`. | | `x-revoply-try-it` | `false` where the reference offers no in-browser console. | | `x-revoply-max-bytes` | The largest request body accepted. | | `x-extensible-enum` | Values known today of a string that may gain more. Accept unknown values. | # Support Source: https://docs.revoplyai.com/resources/support/ > How to get help with webhooks, flow triggers, API connections and the widget, and what to include. Email [support@revoplyai.com](mailto:support@revoplyai.com). Include what lets us find the request in our logs: | About | Include | | ---------------------- | -------------------------------------------------------------------------------------------------------- | | A webhook delivery | The **Delivery ID** (`X-Revoply-Delivery` header, also in the delivery log) and the event `id` (`evt_…`) | | A flow trigger request | The time, the HTTP status, the `status` and `detail` of the answer, and the run `id` if there was one | | A call to your API | The flow or AI action, the time, and the status your system answered | | The widget | The page URL, the browser, and anything the `error` event reported | Always include your account's name and the time, with its time zone. Never send a secret: not a webhook signing secret, a flow trigger URL, or an API connection's credential. We never need them. If you sent one by mistake, rotate or replace it at once; see [Security](/get-started/security/). # appointment.booked Source: https://docs.revoplyai.com/webhooks/events/appointment-booked/ > An appointment was booked. Webhook event `appointment.booked`, delivered as `POST` to your endpoint. **Fires when** an appointment is booked by the assistant in a conversation or by the customer on the booking page. **Does not fire** when the team adds an appointment by hand in the dashboard, or when an appointment is moved to another time. Not for reminders. ## Parameters | Name | In | Required | Description | | --- | --- | --- | --- | | `X-Revoply-Signature` | header | yes | `t=,v1=`, with a second `v1` while a rotated secret still signs. Verify it before trusting the body. | | `X-Revoply-Event` | header | yes | The event's `type`, so a receiver can route before parsing. | | `X-Revoply-Delivery` | header | yes | This delivery's id. A resend of the same event keeps the envelope `id` and gets a new delivery id. | ## Payload ```json { "allOf": [ { "required": [ "id", "type", "apiVersion", "createdAt", "test", "company", "data" ], "type": "object", "properties": { "id": { "type": "string", "description": "The event's id, `evt_…`. The same on every retry and resend.", "example": "evt_4e1b9c2d7a3f4e8b9c0d1e2f3a4b5c6d" }, "type": { "type": "string", "description": "What happened. Treat an unknown value as an event you did not subscribe to, and acknowledge it.\n\nKnown values: `message.received`, `conversation.started`, `conversation.handover`, `conversation.assigned`, `conversation.resolved`, `contact.created`, `contact.updated`, `contact.opted_out`, `lead.qualified`, `appointment.booked`, `appointment.cancelled`, `flow.completed`, `flow.failed`, `ping`. More may be added.", "x-extensible-enum": [ "message.received", "conversation.started", "conversation.handover", "conversation.assigned", "conversation.resolved", "contact.created", "contact.updated", "contact.opted_out", "lead.qualified", "appointment.booked", "appointment.cancelled", "flow.completed", "flow.failed", "ping" ] }, "apiVersion": { "type": "string", "description": "The shape of `data`. A new version is opt-in per endpoint.", "example": "2026-10" }, "createdAt": { "type": "string", "description": "When it happened, UTC.", "format": "date-time" }, "test": { "type": "boolean", "description": "True for what \"Send test\" posts: a sample, about nobody real. Always present." }, "company": { "required": [ "id", "name" ], "type": "object", "properties": { "id": { "type": "string", "description": "The account (or project) it happened in.", "format": "uuid" }, "name": { "type": [ "null", "string" ] } } }, "data": { "type": "object", "description": "Depends on `type`; see each event." } }, "description": "Every event arrives in this envelope. Fields are only ever added, and a field that can be empty is sent as null rather than left out. Deduplicate on `id`: a delivery can arrive more than once." }, { "type": "object", "properties": { "type": { "const": "appointment.booked" }, "data": { "required": [ "appointment", "conversation", "contact" ], "type": "object", "properties": { "appointment": { "required": [ "id", "reference", "service", "startsAt", "endsAt", "status", "customerName", "cancellationReason" ], "type": "object", "properties": { "id": { "type": "string" }, "reference": { "type": [ "null", "string" ] }, "service": { "type": [ "null", "string" ] }, "startsAt": { "type": "string", "format": "date-time" }, "endsAt": { "type": "string", "format": "date-time" }, "status": { "type": "string", "description": "`confirmed`, `cancelled`, `no_show` or `completed`." }, "customerName": { "type": [ "null", "string" ] }, "cancellationReason": { "type": [ "null", "string" ] } } }, "conversation": { "required": [ "id", "channelId", "channelType" ], "type": [ "null", "object" ], "properties": { "id": { "type": "string", "description": "The conversation's id. Opaque; may contain `_`, `.`, `+`, `=` and `@`." }, "channelId": { "type": [ "null", "string" ], "description": "The channel (number, page, bot or widget) it is on." }, "channelType": { "type": [ "null", "string" ], "description": "`whatsapp`, `whatsapp_qr`, `telegram`, `messenger`, `instagram`, `web_widget`… Treat an unknown value as another channel.\n\nKnown values: `whatsapp`, `whatsapp_qr`, `telegram`, `messenger`, `instagram`, `web_widget`, `website`, `voice`. More may be added.", "x-extensible-enum": [ "whatsapp", "whatsapp_qr", "telegram", "messenger", "instagram", "web_widget", "website", "voice" ] } } }, "contact": { "required": [ "id", "name", "phone", "whatsAppId" ], "type": [ "null", "object" ], "properties": { "id": { "type": [ "null", "string" ], "description": "The contact's id; null for a web widget visitor." }, "name": { "type": [ "null", "string" ] }, "phone": { "type": [ "null", "string" ], "description": "E.164, such as `+966501234567`, or null — never a WhatsApp user id." }, "whatsAppId": { "type": [ "null", "string" ], "description": "On WhatsApp, the id WhatsApp knows the customer by: the number without `+`, or a business-scoped user id when WhatsApp withholds the number. Null on other channels." } } } } } } } ] } ``` Example: ```json { "id": "evt_dd925c487485c878d83075c6cb317e1c", "type": "appointment.booked", "apiVersion": "2026-10", "createdAt": "2026-10-01T09:30:05+00:00", "test": false, "company": { "id": "0b3d6f2e-8a41-4c75-9e20-5f7a1c3d9b84", "name": "متجر الرياض" }, "data": { "appointment": { "id": "c84f1a26-3e5b-4d97-8b02-7a6e9d1c5f34", "reference": "BK-7Q4M2", "service": "قص شعر وتصفيف", "startsAt": "2026-10-03T16:30:00+00:00", "endsAt": "2026-10-03T17:30:00+00:00", "status": "confirmed", "customerName": "نورة العتيبي", "cancellationReason": null }, "conversation": { "id": "WhatsApp_3f6a2d18-0b7c-4e91-8a5d-c24e1f9b7a60", "channelId": null, "channelType": null }, "contact": { "id": "3f6a2d18-0b7c-4e91-8a5d-c24e1f9b7a60", "name": "نورة العتيبي", "phone": "+966501234567", "whatsAppId": null } } } ``` ## Responses ### 410 The endpoint is gone for good: it is switched off at once, and the account's owner is told. ### 2XX Received. Answer within 10 seconds and do the work afterwards; the body is kept for your delivery log and otherwise ignored. ### default Anything else, a timeout or no answer is a failure, tried again after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h, then dead-lettered. An endpoint failing 100 times in a row, or for 3 days, is switched off. Tests are never retried. # appointment.cancelled Source: https://docs.revoplyai.com/webhooks/events/appointment-cancelled/ > An appointment was cancelled. Webhook event `appointment.cancelled`, delivered as `POST` to your endpoint. **Fires when** an appointment is cancelled by the customer, the assistant or the team. `cancellationReason` is what was given, if anything. **Does not fire** when an appointment is moved, or merely passes without the customer turning up. ## Parameters | Name | In | Required | Description | | --- | --- | --- | --- | | `X-Revoply-Signature` | header | yes | `t=,v1=`, with a second `v1` while a rotated secret still signs. Verify it before trusting the body. | | `X-Revoply-Event` | header | yes | The event's `type`, so a receiver can route before parsing. | | `X-Revoply-Delivery` | header | yes | This delivery's id. A resend of the same event keeps the envelope `id` and gets a new delivery id. | ## Payload ```json { "allOf": [ { "required": [ "id", "type", "apiVersion", "createdAt", "test", "company", "data" ], "type": "object", "properties": { "id": { "type": "string", "description": "The event's id, `evt_…`. The same on every retry and resend.", "example": "evt_4e1b9c2d7a3f4e8b9c0d1e2f3a4b5c6d" }, "type": { "type": "string", "description": "What happened. Treat an unknown value as an event you did not subscribe to, and acknowledge it.\n\nKnown values: `message.received`, `conversation.started`, `conversation.handover`, `conversation.assigned`, `conversation.resolved`, `contact.created`, `contact.updated`, `contact.opted_out`, `lead.qualified`, `appointment.booked`, `appointment.cancelled`, `flow.completed`, `flow.failed`, `ping`. More may be added.", "x-extensible-enum": [ "message.received", "conversation.started", "conversation.handover", "conversation.assigned", "conversation.resolved", "contact.created", "contact.updated", "contact.opted_out", "lead.qualified", "appointment.booked", "appointment.cancelled", "flow.completed", "flow.failed", "ping" ] }, "apiVersion": { "type": "string", "description": "The shape of `data`. A new version is opt-in per endpoint.", "example": "2026-10" }, "createdAt": { "type": "string", "description": "When it happened, UTC.", "format": "date-time" }, "test": { "type": "boolean", "description": "True for what \"Send test\" posts: a sample, about nobody real. Always present." }, "company": { "required": [ "id", "name" ], "type": "object", "properties": { "id": { "type": "string", "description": "The account (or project) it happened in.", "format": "uuid" }, "name": { "type": [ "null", "string" ] } } }, "data": { "type": "object", "description": "Depends on `type`; see each event." } }, "description": "Every event arrives in this envelope. Fields are only ever added, and a field that can be empty is sent as null rather than left out. Deduplicate on `id`: a delivery can arrive more than once." }, { "type": "object", "properties": { "type": { "const": "appointment.cancelled" }, "data": { "required": [ "appointment", "conversation", "contact" ], "type": "object", "properties": { "appointment": { "required": [ "id", "reference", "service", "startsAt", "endsAt", "status", "customerName", "cancellationReason" ], "type": "object", "properties": { "id": { "type": "string" }, "reference": { "type": [ "null", "string" ] }, "service": { "type": [ "null", "string" ] }, "startsAt": { "type": "string", "format": "date-time" }, "endsAt": { "type": "string", "format": "date-time" }, "status": { "type": "string", "description": "`confirmed`, `cancelled`, `no_show` or `completed`." }, "customerName": { "type": [ "null", "string" ] }, "cancellationReason": { "type": [ "null", "string" ] } } }, "conversation": { "required": [ "id", "channelId", "channelType" ], "type": [ "null", "object" ], "properties": { "id": { "type": "string", "description": "The conversation's id. Opaque; may contain `_`, `.`, `+`, `=` and `@`." }, "channelId": { "type": [ "null", "string" ], "description": "The channel (number, page, bot or widget) it is on." }, "channelType": { "type": [ "null", "string" ], "description": "`whatsapp`, `whatsapp_qr`, `telegram`, `messenger`, `instagram`, `web_widget`… Treat an unknown value as another channel.\n\nKnown values: `whatsapp`, `whatsapp_qr`, `telegram`, `messenger`, `instagram`, `web_widget`, `website`, `voice`. More may be added.", "x-extensible-enum": [ "whatsapp", "whatsapp_qr", "telegram", "messenger", "instagram", "web_widget", "website", "voice" ] } } }, "contact": { "required": [ "id", "name", "phone", "whatsAppId" ], "type": [ "null", "object" ], "properties": { "id": { "type": [ "null", "string" ], "description": "The contact's id; null for a web widget visitor." }, "name": { "type": [ "null", "string" ] }, "phone": { "type": [ "null", "string" ], "description": "E.164, such as `+966501234567`, or null — never a WhatsApp user id." }, "whatsAppId": { "type": [ "null", "string" ], "description": "On WhatsApp, the id WhatsApp knows the customer by: the number without `+`, or a business-scoped user id when WhatsApp withholds the number. Null on other channels." } } } } } } } ] } ``` Example: ```json { "id": "evt_4f7bfba09c0ddbe25c94ad50aadeb346", "type": "appointment.cancelled", "apiVersion": "2026-10", "createdAt": "2026-10-01T09:30:05+00:00", "test": false, "company": { "id": "0b3d6f2e-8a41-4c75-9e20-5f7a1c3d9b84", "name": "متجر الرياض" }, "data": { "appointment": { "id": "c84f1a26-3e5b-4d97-8b02-7a6e9d1c5f34", "reference": "BK-7Q4M2", "service": "قص شعر وتصفيف", "startsAt": "2026-10-03T16:30:00+00:00", "endsAt": "2026-10-03T17:30:00+00:00", "status": "cancelled", "customerName": "نورة العتيبي", "cancellationReason": "ظرف طارئ، سأحجز موعدًا آخر" }, "conversation": { "id": "WhatsApp_3f6a2d18-0b7c-4e91-8a5d-c24e1f9b7a60", "channelId": null, "channelType": null }, "contact": { "id": "3f6a2d18-0b7c-4e91-8a5d-c24e1f9b7a60", "name": "نورة العتيبي", "phone": "+966501234567", "whatsAppId": null } } } ``` ## Responses ### 410 The endpoint is gone for good: it is switched off at once, and the account's owner is told. ### 2XX Received. Answer within 10 seconds and do the work afterwards; the body is kept for your delivery log and otherwise ignored. ### default Anything else, a timeout or no answer is a failure, tried again after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h, then dead-lettered. An endpoint failing 100 times in a row, or for 3 days, is switched off. Tests are never retried. # contact.created Source: https://docs.revoplyai.com/webhooks/events/contact-created/ > A contact was added. Webhook event `contact.created`, delivered as `POST` to your endpoint. **Fires when** someone writes for the first time on any channel that identifies them, a team member adds a contact by hand, or a flow's hook names a number that was not yet a contact. **Does not fire** for contacts brought in by a CSV or WhatsApp import — one click would be thousands of events. Not for web widget visitors, who have no contact. ## Parameters | Name | In | Required | Description | | --- | --- | --- | --- | | `X-Revoply-Signature` | header | yes | `t=,v1=`, with a second `v1` while a rotated secret still signs. Verify it before trusting the body. | | `X-Revoply-Event` | header | yes | The event's `type`, so a receiver can route before parsing. | | `X-Revoply-Delivery` | header | yes | This delivery's id. A resend of the same event keeps the envelope `id` and gets a new delivery id. | ## Payload ```json { "allOf": [ { "required": [ "id", "type", "apiVersion", "createdAt", "test", "company", "data" ], "type": "object", "properties": { "id": { "type": "string", "description": "The event's id, `evt_…`. The same on every retry and resend.", "example": "evt_4e1b9c2d7a3f4e8b9c0d1e2f3a4b5c6d" }, "type": { "type": "string", "description": "What happened. Treat an unknown value as an event you did not subscribe to, and acknowledge it.\n\nKnown values: `message.received`, `conversation.started`, `conversation.handover`, `conversation.assigned`, `conversation.resolved`, `contact.created`, `contact.updated`, `contact.opted_out`, `lead.qualified`, `appointment.booked`, `appointment.cancelled`, `flow.completed`, `flow.failed`, `ping`. More may be added.", "x-extensible-enum": [ "message.received", "conversation.started", "conversation.handover", "conversation.assigned", "conversation.resolved", "contact.created", "contact.updated", "contact.opted_out", "lead.qualified", "appointment.booked", "appointment.cancelled", "flow.completed", "flow.failed", "ping" ] }, "apiVersion": { "type": "string", "description": "The shape of `data`. A new version is opt-in per endpoint.", "example": "2026-10" }, "createdAt": { "type": "string", "description": "When it happened, UTC.", "format": "date-time" }, "test": { "type": "boolean", "description": "True for what \"Send test\" posts: a sample, about nobody real. Always present." }, "company": { "required": [ "id", "name" ], "type": "object", "properties": { "id": { "type": "string", "description": "The account (or project) it happened in.", "format": "uuid" }, "name": { "type": [ "null", "string" ] } } }, "data": { "type": "object", "description": "Depends on `type`; see each event." } }, "description": "Every event arrives in this envelope. Fields are only ever added, and a field that can be empty is sent as null rather than left out. Deduplicate on `id`: a delivery can arrive more than once." }, { "type": "object", "properties": { "type": { "const": "contact.created" }, "data": { "required": [ "contact" ], "type": "object", "properties": { "contact": { "required": [ "id", "name", "phone", "whatsAppId", "email", "language", "tags", "fields", "optedOut" ], "type": "object", "properties": { "id": { "type": "string" }, "name": { "type": [ "null", "string" ] }, "phone": { "type": [ "null", "string" ], "description": "E.164, such as `+966501234567`, or null — never a WhatsApp user id." }, "whatsAppId": { "type": [ "null", "string" ], "description": "The WhatsApp user id the contact is filed under — a business-scoped id or an `@lid` — as stored; null when only a number is known." }, "email": { "type": [ "null", "string" ] }, "language": { "type": [ "null", "string" ], "description": "`ar`, `en`… The language the customer writes in, when known." }, "tags": { "type": "array", "items": { "type": [ "null", "string" ] } }, "fields": { "type": "object", "additionalProperties": { "type": [ "null", "string" ] }, "description": "The contact's custom fields, by key." }, "optedOut": { "type": "boolean" } } } } } } } ] } ``` Example: ```json { "id": "evt_0034e332cb30bcb521b7a16de0148ed3", "type": "contact.created", "apiVersion": "2026-10", "createdAt": "2026-10-01T09:30:05+00:00", "test": false, "company": { "id": "0b3d6f2e-8a41-4c75-9e20-5f7a1c3d9b84", "name": "متجر الرياض" }, "data": { "contact": { "id": "3f6a2d18-0b7c-4e91-8a5d-c24e1f9b7a60", "name": "نورة العتيبي", "phone": "+966501234567", "whatsAppId": null, "email": "noura@example.com", "language": "ar", "tags": [ "whatsapp", "عميل مميز" ], "fields": { "city": "الرياض", "orderCount": "4" }, "optedOut": false } } } ``` ## Responses ### 410 The endpoint is gone for good: it is switched off at once, and the account's owner is told. ### 2XX Received. Answer within 10 seconds and do the work afterwards; the body is kept for your delivery log and otherwise ignored. ### default Anything else, a timeout or no answer is a failure, tried again after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h, then dead-lettered. An endpoint failing 100 times in a row, or for 3 days, is switched off. Tests are never retried. # contact.opted_out Source: https://docs.revoplyai.com/webhooks/events/contact-opted-out/ > A contact opted out of messages. Webhook event `contact.opted_out`, delivered as `POST` to your endpoint. **Fires when** the customer sends an opt-out keyword, stops broadcasts, or a team member marks them opted out. **Does not fire** when they are already opted out. Opting back in is `contact.updated` with `optedOut: false`. ## Parameters | Name | In | Required | Description | | --- | --- | --- | --- | | `X-Revoply-Signature` | header | yes | `t=,v1=`, with a second `v1` while a rotated secret still signs. Verify it before trusting the body. | | `X-Revoply-Event` | header | yes | The event's `type`, so a receiver can route before parsing. | | `X-Revoply-Delivery` | header | yes | This delivery's id. A resend of the same event keeps the envelope `id` and gets a new delivery id. | ## Payload ```json { "allOf": [ { "required": [ "id", "type", "apiVersion", "createdAt", "test", "company", "data" ], "type": "object", "properties": { "id": { "type": "string", "description": "The event's id, `evt_…`. The same on every retry and resend.", "example": "evt_4e1b9c2d7a3f4e8b9c0d1e2f3a4b5c6d" }, "type": { "type": "string", "description": "What happened. Treat an unknown value as an event you did not subscribe to, and acknowledge it.\n\nKnown values: `message.received`, `conversation.started`, `conversation.handover`, `conversation.assigned`, `conversation.resolved`, `contact.created`, `contact.updated`, `contact.opted_out`, `lead.qualified`, `appointment.booked`, `appointment.cancelled`, `flow.completed`, `flow.failed`, `ping`. More may be added.", "x-extensible-enum": [ "message.received", "conversation.started", "conversation.handover", "conversation.assigned", "conversation.resolved", "contact.created", "contact.updated", "contact.opted_out", "lead.qualified", "appointment.booked", "appointment.cancelled", "flow.completed", "flow.failed", "ping" ] }, "apiVersion": { "type": "string", "description": "The shape of `data`. A new version is opt-in per endpoint.", "example": "2026-10" }, "createdAt": { "type": "string", "description": "When it happened, UTC.", "format": "date-time" }, "test": { "type": "boolean", "description": "True for what \"Send test\" posts: a sample, about nobody real. Always present." }, "company": { "required": [ "id", "name" ], "type": "object", "properties": { "id": { "type": "string", "description": "The account (or project) it happened in.", "format": "uuid" }, "name": { "type": [ "null", "string" ] } } }, "data": { "type": "object", "description": "Depends on `type`; see each event." } }, "description": "Every event arrives in this envelope. Fields are only ever added, and a field that can be empty is sent as null rather than left out. Deduplicate on `id`: a delivery can arrive more than once." }, { "type": "object", "properties": { "type": { "const": "contact.opted_out" }, "data": { "required": [ "contact" ], "type": "object", "properties": { "contact": { "required": [ "id", "name", "phone", "whatsAppId", "email", "language", "tags", "fields", "optedOut" ], "type": "object", "properties": { "id": { "type": "string" }, "name": { "type": [ "null", "string" ] }, "phone": { "type": [ "null", "string" ], "description": "E.164, such as `+966501234567`, or null — never a WhatsApp user id." }, "whatsAppId": { "type": [ "null", "string" ], "description": "The WhatsApp user id the contact is filed under — a business-scoped id or an `@lid` — as stored; null when only a number is known." }, "email": { "type": [ "null", "string" ] }, "language": { "type": [ "null", "string" ], "description": "`ar`, `en`… The language the customer writes in, when known." }, "tags": { "type": "array", "items": { "type": [ "null", "string" ] } }, "fields": { "type": "object", "additionalProperties": { "type": [ "null", "string" ] }, "description": "The contact's custom fields, by key." }, "optedOut": { "type": "boolean" } } } } } } } ] } ``` Example: ```json { "id": "evt_fa7add3890a70571c215b5a65216015a", "type": "contact.opted_out", "apiVersion": "2026-10", "createdAt": "2026-10-01T09:30:05+00:00", "test": false, "company": { "id": "0b3d6f2e-8a41-4c75-9e20-5f7a1c3d9b84", "name": "متجر الرياض" }, "data": { "contact": { "id": "3f6a2d18-0b7c-4e91-8a5d-c24e1f9b7a60", "name": "نورة العتيبي", "phone": "+966501234567", "whatsAppId": null, "email": "noura@example.com", "language": "ar", "tags": [ "whatsapp", "عميل مميز" ], "fields": { "city": "الرياض", "orderCount": "4" }, "optedOut": true } } } ``` ## Responses ### 410 The endpoint is gone for good: it is switched off at once, and the account's owner is told. ### 2XX Received. Answer within 10 seconds and do the work afterwards; the body is kept for your delivery log and otherwise ignored. ### default Anything else, a timeout or no answer is a failure, tried again after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h, then dead-lettered. An endpoint failing 100 times in a row, or for 3 days, is switched off. Tests are never retried. # contact.updated Source: https://docs.revoplyai.com/webhooks/events/contact-updated/ > A contact's details changed. Webhook event `contact.updated`, delivered as `POST` to your endpoint. **Fires when** a team member edits a contact's name, identifiers, tags or custom fields, a flow's step saves a field or a tag to it, or a contact who had opted out is opted back in. **Does not fire** when a contact merely writes again: the last-seen time is not news. Not for imports. Opting out is `contact.opted_out` instead. ## Parameters | Name | In | Required | Description | | --- | --- | --- | --- | | `X-Revoply-Signature` | header | yes | `t=,v1=`, with a second `v1` while a rotated secret still signs. Verify it before trusting the body. | | `X-Revoply-Event` | header | yes | The event's `type`, so a receiver can route before parsing. | | `X-Revoply-Delivery` | header | yes | This delivery's id. A resend of the same event keeps the envelope `id` and gets a new delivery id. | ## Payload ```json { "allOf": [ { "required": [ "id", "type", "apiVersion", "createdAt", "test", "company", "data" ], "type": "object", "properties": { "id": { "type": "string", "description": "The event's id, `evt_…`. The same on every retry and resend.", "example": "evt_4e1b9c2d7a3f4e8b9c0d1e2f3a4b5c6d" }, "type": { "type": "string", "description": "What happened. Treat an unknown value as an event you did not subscribe to, and acknowledge it.\n\nKnown values: `message.received`, `conversation.started`, `conversation.handover`, `conversation.assigned`, `conversation.resolved`, `contact.created`, `contact.updated`, `contact.opted_out`, `lead.qualified`, `appointment.booked`, `appointment.cancelled`, `flow.completed`, `flow.failed`, `ping`. More may be added.", "x-extensible-enum": [ "message.received", "conversation.started", "conversation.handover", "conversation.assigned", "conversation.resolved", "contact.created", "contact.updated", "contact.opted_out", "lead.qualified", "appointment.booked", "appointment.cancelled", "flow.completed", "flow.failed", "ping" ] }, "apiVersion": { "type": "string", "description": "The shape of `data`. A new version is opt-in per endpoint.", "example": "2026-10" }, "createdAt": { "type": "string", "description": "When it happened, UTC.", "format": "date-time" }, "test": { "type": "boolean", "description": "True for what \"Send test\" posts: a sample, about nobody real. Always present." }, "company": { "required": [ "id", "name" ], "type": "object", "properties": { "id": { "type": "string", "description": "The account (or project) it happened in.", "format": "uuid" }, "name": { "type": [ "null", "string" ] } } }, "data": { "type": "object", "description": "Depends on `type`; see each event." } }, "description": "Every event arrives in this envelope. Fields are only ever added, and a field that can be empty is sent as null rather than left out. Deduplicate on `id`: a delivery can arrive more than once." }, { "type": "object", "properties": { "type": { "const": "contact.updated" }, "data": { "required": [ "contact" ], "type": "object", "properties": { "contact": { "required": [ "id", "name", "phone", "whatsAppId", "email", "language", "tags", "fields", "optedOut" ], "type": "object", "properties": { "id": { "type": "string" }, "name": { "type": [ "null", "string" ] }, "phone": { "type": [ "null", "string" ], "description": "E.164, such as `+966501234567`, or null — never a WhatsApp user id." }, "whatsAppId": { "type": [ "null", "string" ], "description": "The WhatsApp user id the contact is filed under — a business-scoped id or an `@lid` — as stored; null when only a number is known." }, "email": { "type": [ "null", "string" ] }, "language": { "type": [ "null", "string" ], "description": "`ar`, `en`… The language the customer writes in, when known." }, "tags": { "type": "array", "items": { "type": [ "null", "string" ] } }, "fields": { "type": "object", "additionalProperties": { "type": [ "null", "string" ] }, "description": "The contact's custom fields, by key." }, "optedOut": { "type": "boolean" } } } } } } } ] } ``` Example: ```json { "id": "evt_718f639779077f75cdfaa77247a5da61", "type": "contact.updated", "apiVersion": "2026-10", "createdAt": "2026-10-01T09:30:05+00:00", "test": false, "company": { "id": "0b3d6f2e-8a41-4c75-9e20-5f7a1c3d9b84", "name": "متجر الرياض" }, "data": { "contact": { "id": "3f6a2d18-0b7c-4e91-8a5d-c24e1f9b7a60", "name": "نورة العتيبي", "phone": "+966501234567", "whatsAppId": null, "email": "noura@example.com", "language": "ar", "tags": [ "whatsapp", "عميل مميز" ], "fields": { "city": "الرياض", "orderCount": "4" }, "optedOut": false } } } ``` ## Responses ### 410 The endpoint is gone for good: it is switched off at once, and the account's owner is told. ### 2XX Received. Answer within 10 seconds and do the work afterwards; the body is kept for your delivery log and otherwise ignored. ### default Anything else, a timeout or no answer is a failure, tried again after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h, then dead-lettered. An endpoint failing 100 times in a row, or for 3 days, is switched off. Tests are never retried. # conversation.assigned Source: https://docs.revoplyai.com/webhooks/events/conversation-assigned/ > The conversation was assigned, or unassigned. Webhook event `conversation.assigned`, delivered as `POST` to your endpoint. **Fires when** a team member assigns the conversation to someone, reassigns it, or clears the assignee (then `assignee` is null). **Does not fire** when the assignee does not change. Not for handovers to the team as a whole; that is `conversation.handover`. ## Parameters | Name | In | Required | Description | | --- | --- | --- | --- | | `X-Revoply-Signature` | header | yes | `t=,v1=`, with a second `v1` while a rotated secret still signs. Verify it before trusting the body. | | `X-Revoply-Event` | header | yes | The event's `type`, so a receiver can route before parsing. | | `X-Revoply-Delivery` | header | yes | This delivery's id. A resend of the same event keeps the envelope `id` and gets a new delivery id. | ## Payload ```json { "allOf": [ { "required": [ "id", "type", "apiVersion", "createdAt", "test", "company", "data" ], "type": "object", "properties": { "id": { "type": "string", "description": "The event's id, `evt_…`. The same on every retry and resend.", "example": "evt_4e1b9c2d7a3f4e8b9c0d1e2f3a4b5c6d" }, "type": { "type": "string", "description": "What happened. Treat an unknown value as an event you did not subscribe to, and acknowledge it.\n\nKnown values: `message.received`, `conversation.started`, `conversation.handover`, `conversation.assigned`, `conversation.resolved`, `contact.created`, `contact.updated`, `contact.opted_out`, `lead.qualified`, `appointment.booked`, `appointment.cancelled`, `flow.completed`, `flow.failed`, `ping`. More may be added.", "x-extensible-enum": [ "message.received", "conversation.started", "conversation.handover", "conversation.assigned", "conversation.resolved", "contact.created", "contact.updated", "contact.opted_out", "lead.qualified", "appointment.booked", "appointment.cancelled", "flow.completed", "flow.failed", "ping" ] }, "apiVersion": { "type": "string", "description": "The shape of `data`. A new version is opt-in per endpoint.", "example": "2026-10" }, "createdAt": { "type": "string", "description": "When it happened, UTC.", "format": "date-time" }, "test": { "type": "boolean", "description": "True for what \"Send test\" posts: a sample, about nobody real. Always present." }, "company": { "required": [ "id", "name" ], "type": "object", "properties": { "id": { "type": "string", "description": "The account (or project) it happened in.", "format": "uuid" }, "name": { "type": [ "null", "string" ] } } }, "data": { "type": "object", "description": "Depends on `type`; see each event." } }, "description": "Every event arrives in this envelope. Fields are only ever added, and a field that can be empty is sent as null rather than left out. Deduplicate on `id`: a delivery can arrive more than once." }, { "type": "object", "properties": { "type": { "const": "conversation.assigned" }, "data": { "required": [ "conversation", "assignee" ], "type": "object", "properties": { "conversation": { "required": [ "id", "channelId", "channelType" ], "type": "object", "properties": { "id": { "type": "string", "description": "The conversation's id. Opaque; may contain `_`, `.`, `+`, `=` and `@`." }, "channelId": { "type": [ "null", "string" ], "description": "The channel (number, page, bot or widget) it is on." }, "channelType": { "type": [ "null", "string" ], "description": "`whatsapp`, `whatsapp_qr`, `telegram`, `messenger`, `instagram`, `web_widget`… Treat an unknown value as another channel.\n\nKnown values: `whatsapp`, `whatsapp_qr`, `telegram`, `messenger`, `instagram`, `web_widget`, `website`, `voice`. More may be added.", "x-extensible-enum": [ "whatsapp", "whatsapp_qr", "telegram", "messenger", "instagram", "web_widget", "website", "voice" ] } } }, "assignee": { "required": [ "id", "name", "email" ], "type": [ "null", "object" ], "properties": { "id": { "type": [ "null", "string" ] }, "name": { "type": [ "null", "string" ] }, "email": { "type": [ "null", "string" ] } } } } } } } ] } ``` Example: ```json { "id": "evt_724f47a1bc59c78b8c9dd93b2f0fe1ba", "type": "conversation.assigned", "apiVersion": "2026-10", "createdAt": "2026-10-01T09:30:05+00:00", "test": false, "company": { "id": "0b3d6f2e-8a41-4c75-9e20-5f7a1c3d9b84", "name": "متجر الرياض" }, "data": { "conversation": { "id": "WhatsApp_3f6a2d18-0b7c-4e91-8a5d-c24e1f9b7a60", "channelId": "7c1e4b2a-9d3f-4e58-a6b1-2f0c8d7e5a13", "channelType": "whatsapp" }, "assignee": { "id": "a91d3c5e-7f20-4b86-9e14-6d8b2c0f3a57", "name": "خالد الشهري", "email": "khaled@example.com" } } } ``` ## Responses ### 410 The endpoint is gone for good: it is switched off at once, and the account's owner is told. ### 2XX Received. Answer within 10 seconds and do the work afterwards; the body is kept for your delivery log and otherwise ignored. ### default Anything else, a timeout or no answer is a failure, tried again after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h, then dead-lettered. An endpoint failing 100 times in a row, or for 3 days, is switched off. Tests are never retried. # conversation.handover Source: https://docs.revoplyai.com/webhooks/events/conversation-handover/ > The conversation was handed to the team. Webhook event `conversation.handover`, delivered as `POST` to your endpoint. **Fires when** the assistant or the platform hands a conversation to a person: the customer asked for one, the assistant could not answer, the customer is upset, a flow reached a handover step, a lead qualified for handoff, or loop protection stepped in. `reason` says which, as a stable code; `reasonText` is the assistant's own words when it gave any. **Does not fire** again while the same handover is still open — a customer who asks twice raises it once. Not when a team member simply replies or is assigned; that is `conversation.assigned`. ## Parameters | Name | In | Required | Description | | --- | --- | --- | --- | | `X-Revoply-Signature` | header | yes | `t=,v1=`, with a second `v1` while a rotated secret still signs. Verify it before trusting the body. | | `X-Revoply-Event` | header | yes | The event's `type`, so a receiver can route before parsing. | | `X-Revoply-Delivery` | header | yes | This delivery's id. A resend of the same event keeps the envelope `id` and gets a new delivery id. | ## Payload ```json { "allOf": [ { "required": [ "id", "type", "apiVersion", "createdAt", "test", "company", "data" ], "type": "object", "properties": { "id": { "type": "string", "description": "The event's id, `evt_…`. The same on every retry and resend.", "example": "evt_4e1b9c2d7a3f4e8b9c0d1e2f3a4b5c6d" }, "type": { "type": "string", "description": "What happened. Treat an unknown value as an event you did not subscribe to, and acknowledge it.\n\nKnown values: `message.received`, `conversation.started`, `conversation.handover`, `conversation.assigned`, `conversation.resolved`, `contact.created`, `contact.updated`, `contact.opted_out`, `lead.qualified`, `appointment.booked`, `appointment.cancelled`, `flow.completed`, `flow.failed`, `ping`. More may be added.", "x-extensible-enum": [ "message.received", "conversation.started", "conversation.handover", "conversation.assigned", "conversation.resolved", "contact.created", "contact.updated", "contact.opted_out", "lead.qualified", "appointment.booked", "appointment.cancelled", "flow.completed", "flow.failed", "ping" ] }, "apiVersion": { "type": "string", "description": "The shape of `data`. A new version is opt-in per endpoint.", "example": "2026-10" }, "createdAt": { "type": "string", "description": "When it happened, UTC.", "format": "date-time" }, "test": { "type": "boolean", "description": "True for what \"Send test\" posts: a sample, about nobody real. Always present." }, "company": { "required": [ "id", "name" ], "type": "object", "properties": { "id": { "type": "string", "description": "The account (or project) it happened in.", "format": "uuid" }, "name": { "type": [ "null", "string" ] } } }, "data": { "type": "object", "description": "Depends on `type`; see each event." } }, "description": "Every event arrives in this envelope. Fields are only ever added, and a field that can be empty is sent as null rather than left out. Deduplicate on `id`: a delivery can arrive more than once." }, { "type": "object", "properties": { "type": { "const": "conversation.handover" }, "data": { "required": [ "conversation", "contact", "reason", "reasonText" ], "type": "object", "properties": { "conversation": { "required": [ "id", "channelId", "channelType" ], "type": "object", "properties": { "id": { "type": "string", "description": "The conversation's id. Opaque; may contain `_`, `.`, `+`, `=` and `@`." }, "channelId": { "type": [ "null", "string" ], "description": "The channel (number, page, bot or widget) it is on." }, "channelType": { "type": [ "null", "string" ], "description": "`whatsapp`, `whatsapp_qr`, `telegram`, `messenger`, `instagram`, `web_widget`… Treat an unknown value as another channel.\n\nKnown values: `whatsapp`, `whatsapp_qr`, `telegram`, `messenger`, `instagram`, `web_widget`, `website`, `voice`. More may be added.", "x-extensible-enum": [ "whatsapp", "whatsapp_qr", "telegram", "messenger", "instagram", "web_widget", "website", "voice" ] } } }, "contact": { "required": [ "id", "name", "phone", "whatsAppId" ], "type": [ "null", "object" ], "properties": { "id": { "type": [ "null", "string" ], "description": "The contact's id; null for a web widget visitor." }, "name": { "type": [ "null", "string" ] }, "phone": { "type": [ "null", "string" ], "description": "E.164, such as `+966501234567`, or null — never a WhatsApp user id." }, "whatsAppId": { "type": [ "null", "string" ], "description": "On WhatsApp, the id WhatsApp knows the customer by: the number without `+`, or a business-scoped user id when WhatsApp withholds the number. Null on other channels." } } }, "reason": { "type": "string", "description": "`customer_asked_for_person`, `missing_information`, `needs_staff_action`, `customer_upset`, `qualified_lead`, `flow_handover`, `message_burst`, `unsupported_media`… Treat an unknown value as `other`." }, "reasonText": { "type": [ "null", "string" ], "description": "The assistant's own words, when it gave any. For people; may be Arabic." } } } } } ] } ``` Example: ```json { "id": "evt_cd92ffc66b8593f1dda08ca5c42ab051", "type": "conversation.handover", "apiVersion": "2026-10", "createdAt": "2026-10-01T09:30:05+00:00", "test": false, "company": { "id": "0b3d6f2e-8a41-4c75-9e20-5f7a1c3d9b84", "name": "متجر الرياض" }, "data": { "conversation": { "id": "WhatsApp_3f6a2d18-0b7c-4e91-8a5d-c24e1f9b7a60", "channelId": "7c1e4b2a-9d3f-4e58-a6b1-2f0c8d7e5a13", "channelType": "whatsapp" }, "contact": { "id": "3f6a2d18-0b7c-4e91-8a5d-c24e1f9b7a60", "name": null, "phone": "+966501234567", "whatsAppId": "966501234567" }, "reason": "customer_asked_for_person", "reasonText": "العميلة تطلب التحدث مع موظف بخصوص استرجاع المبلغ" } } ``` ## Responses ### 410 The endpoint is gone for good: it is switched off at once, and the account's owner is told. ### 2XX Received. Answer within 10 seconds and do the work afterwards; the body is kept for your delivery log and otherwise ignored. ### default Anything else, a timeout or no answer is a failure, tried again after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h, then dead-lettered. An endpoint failing 100 times in a row, or for 3 days, is switched off. Tests are never retried. # conversation.resolved Source: https://docs.revoplyai.com/webhooks/events/conversation-resolved/ > The conversation was resolved or closed. Webhook event `conversation.resolved`, delivered as `POST` to your endpoint. **Fires when** a team member marks a conversation resolved or closed, one at a time or in bulk. A bulk close names each conversation by its `id` alone: `channelId`, `channelType` and `contact` are null. **Does not fire** when a conversation merely goes quiet, and when the assistant finishes answering — conversations are only resolved by people. ## Parameters | Name | In | Required | Description | | --- | --- | --- | --- | | `X-Revoply-Signature` | header | yes | `t=,v1=`, with a second `v1` while a rotated secret still signs. Verify it before trusting the body. | | `X-Revoply-Event` | header | yes | The event's `type`, so a receiver can route before parsing. | | `X-Revoply-Delivery` | header | yes | This delivery's id. A resend of the same event keeps the envelope `id` and gets a new delivery id. | ## Payload ```json { "allOf": [ { "required": [ "id", "type", "apiVersion", "createdAt", "test", "company", "data" ], "type": "object", "properties": { "id": { "type": "string", "description": "The event's id, `evt_…`. The same on every retry and resend.", "example": "evt_4e1b9c2d7a3f4e8b9c0d1e2f3a4b5c6d" }, "type": { "type": "string", "description": "What happened. Treat an unknown value as an event you did not subscribe to, and acknowledge it.\n\nKnown values: `message.received`, `conversation.started`, `conversation.handover`, `conversation.assigned`, `conversation.resolved`, `contact.created`, `contact.updated`, `contact.opted_out`, `lead.qualified`, `appointment.booked`, `appointment.cancelled`, `flow.completed`, `flow.failed`, `ping`. More may be added.", "x-extensible-enum": [ "message.received", "conversation.started", "conversation.handover", "conversation.assigned", "conversation.resolved", "contact.created", "contact.updated", "contact.opted_out", "lead.qualified", "appointment.booked", "appointment.cancelled", "flow.completed", "flow.failed", "ping" ] }, "apiVersion": { "type": "string", "description": "The shape of `data`. A new version is opt-in per endpoint.", "example": "2026-10" }, "createdAt": { "type": "string", "description": "When it happened, UTC.", "format": "date-time" }, "test": { "type": "boolean", "description": "True for what \"Send test\" posts: a sample, about nobody real. Always present." }, "company": { "required": [ "id", "name" ], "type": "object", "properties": { "id": { "type": "string", "description": "The account (or project) it happened in.", "format": "uuid" }, "name": { "type": [ "null", "string" ] } } }, "data": { "type": "object", "description": "Depends on `type`; see each event." } }, "description": "Every event arrives in this envelope. Fields are only ever added, and a field that can be empty is sent as null rather than left out. Deduplicate on `id`: a delivery can arrive more than once." }, { "type": "object", "properties": { "type": { "const": "conversation.resolved" }, "data": { "required": [ "conversation", "contact" ], "type": "object", "properties": { "conversation": { "required": [ "id", "channelId", "channelType" ], "type": "object", "properties": { "id": { "type": "string", "description": "The conversation's id. Opaque; may contain `_`, `.`, `+`, `=` and `@`." }, "channelId": { "type": [ "null", "string" ], "description": "The channel (number, page, bot or widget) it is on." }, "channelType": { "type": [ "null", "string" ], "description": "`whatsapp`, `whatsapp_qr`, `telegram`, `messenger`, `instagram`, `web_widget`… Treat an unknown value as another channel.\n\nKnown values: `whatsapp`, `whatsapp_qr`, `telegram`, `messenger`, `instagram`, `web_widget`, `website`, `voice`. More may be added.", "x-extensible-enum": [ "whatsapp", "whatsapp_qr", "telegram", "messenger", "instagram", "web_widget", "website", "voice" ] } } }, "contact": { "required": [ "id", "name", "phone", "whatsAppId" ], "type": [ "null", "object" ], "properties": { "id": { "type": [ "null", "string" ], "description": "The contact's id; null for a web widget visitor." }, "name": { "type": [ "null", "string" ] }, "phone": { "type": [ "null", "string" ], "description": "E.164, such as `+966501234567`, or null — never a WhatsApp user id." }, "whatsAppId": { "type": [ "null", "string" ], "description": "On WhatsApp, the id WhatsApp knows the customer by: the number without `+`, or a business-scoped user id when WhatsApp withholds the number. Null on other channels." } } } } } } } ] } ``` Example: ```json { "id": "evt_3d224cab9d71622c12be691fefd09422", "type": "conversation.resolved", "apiVersion": "2026-10", "createdAt": "2026-10-01T09:30:05+00:00", "test": false, "company": { "id": "0b3d6f2e-8a41-4c75-9e20-5f7a1c3d9b84", "name": "متجر الرياض" }, "data": { "conversation": { "id": "WhatsApp_3f6a2d18-0b7c-4e91-8a5d-c24e1f9b7a60", "channelId": "7c1e4b2a-9d3f-4e58-a6b1-2f0c8d7e5a13", "channelType": "whatsapp" }, "contact": { "id": "3f6a2d18-0b7c-4e91-8a5d-c24e1f9b7a60", "name": "نورة العتيبي", "phone": "+966501234567", "whatsAppId": "966501234567" } } } ``` ## Responses ### 410 The endpoint is gone for good: it is switched off at once, and the account's owner is told. ### 2XX Received. Answer within 10 seconds and do the work afterwards; the body is kept for your delivery log and otherwise ignored. ### default Anything else, a timeout or no answer is a failure, tried again after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h, then dead-lettered. An endpoint failing 100 times in a row, or for 3 days, is switched off. Tests are never retried. # conversation.started Source: https://docs.revoplyai.com/webhooks/events/conversation-started/ > A new conversation began. Webhook event `conversation.started`, delivered as `POST` to your endpoint. **Fires when** the first time a customer writes on a channel and a conversation is created for them, and when a flow's hook starts a conversation with a number that had none. Once per conversation. **Does not fire** when a customer writes again in a conversation that already exists, even one resolved long ago — that is `message.received`. Not when a conversation is reopened or reassigned. ## Parameters | Name | In | Required | Description | | --- | --- | --- | --- | | `X-Revoply-Signature` | header | yes | `t=,v1=`, with a second `v1` while a rotated secret still signs. Verify it before trusting the body. | | `X-Revoply-Event` | header | yes | The event's `type`, so a receiver can route before parsing. | | `X-Revoply-Delivery` | header | yes | This delivery's id. A resend of the same event keeps the envelope `id` and gets a new delivery id. | ## Payload ```json { "allOf": [ { "required": [ "id", "type", "apiVersion", "createdAt", "test", "company", "data" ], "type": "object", "properties": { "id": { "type": "string", "description": "The event's id, `evt_…`. The same on every retry and resend.", "example": "evt_4e1b9c2d7a3f4e8b9c0d1e2f3a4b5c6d" }, "type": { "type": "string", "description": "What happened. Treat an unknown value as an event you did not subscribe to, and acknowledge it.\n\nKnown values: `message.received`, `conversation.started`, `conversation.handover`, `conversation.assigned`, `conversation.resolved`, `contact.created`, `contact.updated`, `contact.opted_out`, `lead.qualified`, `appointment.booked`, `appointment.cancelled`, `flow.completed`, `flow.failed`, `ping`. More may be added.", "x-extensible-enum": [ "message.received", "conversation.started", "conversation.handover", "conversation.assigned", "conversation.resolved", "contact.created", "contact.updated", "contact.opted_out", "lead.qualified", "appointment.booked", "appointment.cancelled", "flow.completed", "flow.failed", "ping" ] }, "apiVersion": { "type": "string", "description": "The shape of `data`. A new version is opt-in per endpoint.", "example": "2026-10" }, "createdAt": { "type": "string", "description": "When it happened, UTC.", "format": "date-time" }, "test": { "type": "boolean", "description": "True for what \"Send test\" posts: a sample, about nobody real. Always present." }, "company": { "required": [ "id", "name" ], "type": "object", "properties": { "id": { "type": "string", "description": "The account (or project) it happened in.", "format": "uuid" }, "name": { "type": [ "null", "string" ] } } }, "data": { "type": "object", "description": "Depends on `type`; see each event." } }, "description": "Every event arrives in this envelope. Fields are only ever added, and a field that can be empty is sent as null rather than left out. Deduplicate on `id`: a delivery can arrive more than once." }, { "type": "object", "properties": { "type": { "const": "conversation.started" }, "data": { "required": [ "conversation", "contact" ], "type": "object", "properties": { "conversation": { "required": [ "id", "channelId", "channelType" ], "type": "object", "properties": { "id": { "type": "string", "description": "The conversation's id. Opaque; may contain `_`, `.`, `+`, `=` and `@`." }, "channelId": { "type": [ "null", "string" ], "description": "The channel (number, page, bot or widget) it is on." }, "channelType": { "type": [ "null", "string" ], "description": "`whatsapp`, `whatsapp_qr`, `telegram`, `messenger`, `instagram`, `web_widget`… Treat an unknown value as another channel.\n\nKnown values: `whatsapp`, `whatsapp_qr`, `telegram`, `messenger`, `instagram`, `web_widget`, `website`, `voice`. More may be added.", "x-extensible-enum": [ "whatsapp", "whatsapp_qr", "telegram", "messenger", "instagram", "web_widget", "website", "voice" ] } } }, "contact": { "required": [ "id", "name", "phone", "whatsAppId" ], "type": [ "null", "object" ], "properties": { "id": { "type": [ "null", "string" ], "description": "The contact's id; null for a web widget visitor." }, "name": { "type": [ "null", "string" ] }, "phone": { "type": [ "null", "string" ], "description": "E.164, such as `+966501234567`, or null — never a WhatsApp user id." }, "whatsAppId": { "type": [ "null", "string" ], "description": "On WhatsApp, the id WhatsApp knows the customer by: the number without `+`, or a business-scoped user id when WhatsApp withholds the number. Null on other channels." } } } } } } } ] } ``` Example: ```json { "id": "evt_97b319133f7563d685e9cbbbac6968cd", "type": "conversation.started", "apiVersion": "2026-10", "createdAt": "2026-10-01T09:30:05+00:00", "test": false, "company": { "id": "0b3d6f2e-8a41-4c75-9e20-5f7a1c3d9b84", "name": "متجر الرياض" }, "data": { "conversation": { "id": "WhatsApp_3f6a2d18-0b7c-4e91-8a5d-c24e1f9b7a60", "channelId": "7c1e4b2a-9d3f-4e58-a6b1-2f0c8d7e5a13", "channelType": "whatsapp" }, "contact": { "id": "3f6a2d18-0b7c-4e91-8a5d-c24e1f9b7a60", "name": "نورة العتيبي", "phone": "+966501234567", "whatsAppId": "966501234567" } } } ``` ## Responses ### 410 The endpoint is gone for good: it is switched off at once, and the account's owner is told. ### 2XX Received. Answer within 10 seconds and do the work afterwards; the body is kept for your delivery log and otherwise ignored. ### default Anything else, a timeout or no answer is a failure, tried again after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h, then dead-lettered. An endpoint failing 100 times in a row, or for 3 days, is switched off. Tests are never retried. # flow.completed Source: https://docs.revoplyai.com/webhooks/events/flow-completed/ > An automation finished. Webhook event `flow.completed`, delivered as `POST` to your endpoint. **Fires when** a flow run ends as completed: it reached its end, handed over to the team, the customer typed an exit word, or the assistant took over an answer the flow could not use. `run.endReason` says which; `run.variables` holds what it collected. **Does not fire** for runs that were cancelled, skipped or timed out — those are not announced. ## Parameters | Name | In | Required | Description | | --- | --- | --- | --- | | `X-Revoply-Signature` | header | yes | `t=,v1=`, with a second `v1` while a rotated secret still signs. Verify it before trusting the body. | | `X-Revoply-Event` | header | yes | The event's `type`, so a receiver can route before parsing. | | `X-Revoply-Delivery` | header | yes | This delivery's id. A resend of the same event keeps the envelope `id` and gets a new delivery id. | ## Payload ```json { "allOf": [ { "required": [ "id", "type", "apiVersion", "createdAt", "test", "company", "data" ], "type": "object", "properties": { "id": { "type": "string", "description": "The event's id, `evt_…`. The same on every retry and resend.", "example": "evt_4e1b9c2d7a3f4e8b9c0d1e2f3a4b5c6d" }, "type": { "type": "string", "description": "What happened. Treat an unknown value as an event you did not subscribe to, and acknowledge it.\n\nKnown values: `message.received`, `conversation.started`, `conversation.handover`, `conversation.assigned`, `conversation.resolved`, `contact.created`, `contact.updated`, `contact.opted_out`, `lead.qualified`, `appointment.booked`, `appointment.cancelled`, `flow.completed`, `flow.failed`, `ping`. More may be added.", "x-extensible-enum": [ "message.received", "conversation.started", "conversation.handover", "conversation.assigned", "conversation.resolved", "contact.created", "contact.updated", "contact.opted_out", "lead.qualified", "appointment.booked", "appointment.cancelled", "flow.completed", "flow.failed", "ping" ] }, "apiVersion": { "type": "string", "description": "The shape of `data`. A new version is opt-in per endpoint.", "example": "2026-10" }, "createdAt": { "type": "string", "description": "When it happened, UTC.", "format": "date-time" }, "test": { "type": "boolean", "description": "True for what \"Send test\" posts: a sample, about nobody real. Always present." }, "company": { "required": [ "id", "name" ], "type": "object", "properties": { "id": { "type": "string", "description": "The account (or project) it happened in.", "format": "uuid" }, "name": { "type": [ "null", "string" ] } } }, "data": { "type": "object", "description": "Depends on `type`; see each event." } }, "description": "Every event arrives in this envelope. Fields are only ever added, and a field that can be empty is sent as null rather than left out. Deduplicate on `id`: a delivery can arrive more than once." }, { "type": "object", "properties": { "type": { "const": "flow.completed" }, "data": { "required": [ "flow", "run", "conversation", "contact" ], "type": "object", "properties": { "flow": { "required": [ "id", "name" ], "type": "object", "properties": { "id": { "type": "string" }, "name": { "type": "string" } } }, "run": { "required": [ "id", "status", "endReason", "triggerKind", "startedAt", "endedAt", "variables" ], "type": "object", "properties": { "id": { "type": "string" }, "status": { "type": "string", "description": "`completed` or `failed`, as the event's type says." }, "endReason": { "type": [ "null", "string" ], "description": "Why it ended: `completed`, `handed_over`, `customer_exited`, `error`, `limit_exceeded`… Treat an unknown value as its status." }, "triggerKind": { "type": "string", "description": "What started it: `webhook`, `keyword`, `conversation_started`, `manual`… May grow." }, "startedAt": { "type": "string", "format": "date-time" }, "endedAt": { "type": [ "null", "string" ], "format": "date-time" }, "variables": { "type": "object", "additionalProperties": { "type": [ "null", "string" ] }, "description": "What the run collected — the answers to its questions and the values it saved, as strings." } } }, "conversation": { "required": [ "id", "channelId", "channelType" ], "type": "object", "properties": { "id": { "type": "string", "description": "The conversation's id. Opaque; may contain `_`, `.`, `+`, `=` and `@`." }, "channelId": { "type": [ "null", "string" ], "description": "The channel (number, page, bot or widget) it is on." }, "channelType": { "type": [ "null", "string" ], "description": "`whatsapp`, `whatsapp_qr`, `telegram`, `messenger`, `instagram`, `web_widget`… Treat an unknown value as another channel.\n\nKnown values: `whatsapp`, `whatsapp_qr`, `telegram`, `messenger`, `instagram`, `web_widget`, `website`, `voice`. More may be added.", "x-extensible-enum": [ "whatsapp", "whatsapp_qr", "telegram", "messenger", "instagram", "web_widget", "website", "voice" ] } } }, "contact": { "required": [ "id", "name", "phone", "whatsAppId" ], "type": [ "null", "object" ], "properties": { "id": { "type": [ "null", "string" ], "description": "The contact's id; null for a web widget visitor." }, "name": { "type": [ "null", "string" ] }, "phone": { "type": [ "null", "string" ], "description": "E.164, such as `+966501234567`, or null — never a WhatsApp user id." }, "whatsAppId": { "type": [ "null", "string" ], "description": "On WhatsApp, the id WhatsApp knows the customer by: the number without `+`, or a business-scoped user id when WhatsApp withholds the number. Null on other channels." } } } } } } } ] } ``` Example: ```json { "id": "evt_fb3ec83d6924f1f79d8ed6876cfcde23", "type": "flow.completed", "apiVersion": "2026-10", "createdAt": "2026-10-01T09:30:05+00:00", "test": false, "company": { "id": "0b3d6f2e-8a41-4c75-9e20-5f7a1c3d9b84", "name": "متجر الرياض" }, "data": { "flow": { "id": "e2a7c9d4-1b6f-4a38-9c50-8d3e7f2b1a96", "name": "تأكيد الطلب" }, "run": { "id": "b16d4f8a-2c9e-4b73-a5d1-0e8f3c7a9b25", "status": "completed", "endReason": "completed", "triggerKind": "webhook", "startedAt": "2026-10-01T09:30:00+00:00", "endedAt": "2026-10-01T09:33:00+00:00", "variables": { "order_id": "10482", "confirmed": "نعم" } }, "conversation": { "id": "WhatsApp_3f6a2d18-0b7c-4e91-8a5d-c24e1f9b7a60", "channelId": "7c1e4b2a-9d3f-4e58-a6b1-2f0c8d7e5a13", "channelType": "whatsapp" }, "contact": { "id": "3f6a2d18-0b7c-4e91-8a5d-c24e1f9b7a60", "name": "نورة العتيبي", "phone": "+966501234567", "whatsAppId": "966501234567" } } } ``` ## Responses ### 410 The endpoint is gone for good: it is switched off at once, and the account's owner is told. ### 2XX Received. Answer within 10 seconds and do the work afterwards; the body is kept for your delivery log and otherwise ignored. ### default Anything else, a timeout or no answer is a failure, tried again after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h, then dead-lettered. An endpoint failing 100 times in a row, or for 3 days, is switched off. Tests are never retried. # flow.failed Source: https://docs.revoplyai.com/webhooks/events/flow-failed/ > An automation failed. Webhook event `flow.failed`, delivered as `POST` to your endpoint. **Fires when** a flow run ends as failed: a step errored, a limit on steps or messages was reached, or the run was found stuck and ended. **Does not fire** for runs that were cancelled, skipped or timed out. ## Parameters | Name | In | Required | Description | | --- | --- | --- | --- | | `X-Revoply-Signature` | header | yes | `t=,v1=`, with a second `v1` while a rotated secret still signs. Verify it before trusting the body. | | `X-Revoply-Event` | header | yes | The event's `type`, so a receiver can route before parsing. | | `X-Revoply-Delivery` | header | yes | This delivery's id. A resend of the same event keeps the envelope `id` and gets a new delivery id. | ## Payload ```json { "allOf": [ { "required": [ "id", "type", "apiVersion", "createdAt", "test", "company", "data" ], "type": "object", "properties": { "id": { "type": "string", "description": "The event's id, `evt_…`. The same on every retry and resend.", "example": "evt_4e1b9c2d7a3f4e8b9c0d1e2f3a4b5c6d" }, "type": { "type": "string", "description": "What happened. Treat an unknown value as an event you did not subscribe to, and acknowledge it.\n\nKnown values: `message.received`, `conversation.started`, `conversation.handover`, `conversation.assigned`, `conversation.resolved`, `contact.created`, `contact.updated`, `contact.opted_out`, `lead.qualified`, `appointment.booked`, `appointment.cancelled`, `flow.completed`, `flow.failed`, `ping`. More may be added.", "x-extensible-enum": [ "message.received", "conversation.started", "conversation.handover", "conversation.assigned", "conversation.resolved", "contact.created", "contact.updated", "contact.opted_out", "lead.qualified", "appointment.booked", "appointment.cancelled", "flow.completed", "flow.failed", "ping" ] }, "apiVersion": { "type": "string", "description": "The shape of `data`. A new version is opt-in per endpoint.", "example": "2026-10" }, "createdAt": { "type": "string", "description": "When it happened, UTC.", "format": "date-time" }, "test": { "type": "boolean", "description": "True for what \"Send test\" posts: a sample, about nobody real. Always present." }, "company": { "required": [ "id", "name" ], "type": "object", "properties": { "id": { "type": "string", "description": "The account (or project) it happened in.", "format": "uuid" }, "name": { "type": [ "null", "string" ] } } }, "data": { "type": "object", "description": "Depends on `type`; see each event." } }, "description": "Every event arrives in this envelope. Fields are only ever added, and a field that can be empty is sent as null rather than left out. Deduplicate on `id`: a delivery can arrive more than once." }, { "type": "object", "properties": { "type": { "const": "flow.failed" }, "data": { "required": [ "flow", "run", "conversation", "contact" ], "type": "object", "properties": { "flow": { "required": [ "id", "name" ], "type": "object", "properties": { "id": { "type": "string" }, "name": { "type": "string" } } }, "run": { "required": [ "id", "status", "endReason", "triggerKind", "startedAt", "endedAt", "variables" ], "type": "object", "properties": { "id": { "type": "string" }, "status": { "type": "string", "description": "`completed` or `failed`, as the event's type says." }, "endReason": { "type": [ "null", "string" ], "description": "Why it ended: `completed`, `handed_over`, `customer_exited`, `error`, `limit_exceeded`… Treat an unknown value as its status." }, "triggerKind": { "type": "string", "description": "What started it: `webhook`, `keyword`, `conversation_started`, `manual`… May grow." }, "startedAt": { "type": "string", "format": "date-time" }, "endedAt": { "type": [ "null", "string" ], "format": "date-time" }, "variables": { "type": "object", "additionalProperties": { "type": [ "null", "string" ] }, "description": "What the run collected — the answers to its questions and the values it saved, as strings." } } }, "conversation": { "required": [ "id", "channelId", "channelType" ], "type": "object", "properties": { "id": { "type": "string", "description": "The conversation's id. Opaque; may contain `_`, `.`, `+`, `=` and `@`." }, "channelId": { "type": [ "null", "string" ], "description": "The channel (number, page, bot or widget) it is on." }, "channelType": { "type": [ "null", "string" ], "description": "`whatsapp`, `whatsapp_qr`, `telegram`, `messenger`, `instagram`, `web_widget`… Treat an unknown value as another channel.\n\nKnown values: `whatsapp`, `whatsapp_qr`, `telegram`, `messenger`, `instagram`, `web_widget`, `website`, `voice`. More may be added.", "x-extensible-enum": [ "whatsapp", "whatsapp_qr", "telegram", "messenger", "instagram", "web_widget", "website", "voice" ] } } }, "contact": { "required": [ "id", "name", "phone", "whatsAppId" ], "type": [ "null", "object" ], "properties": { "id": { "type": [ "null", "string" ], "description": "The contact's id; null for a web widget visitor." }, "name": { "type": [ "null", "string" ] }, "phone": { "type": [ "null", "string" ], "description": "E.164, such as `+966501234567`, or null — never a WhatsApp user id." }, "whatsAppId": { "type": [ "null", "string" ], "description": "On WhatsApp, the id WhatsApp knows the customer by: the number without `+`, or a business-scoped user id when WhatsApp withholds the number. Null on other channels." } } } } } } } ] } ``` Example: ```json { "id": "evt_d069f8d7af8fcc22e28694f98657b599", "type": "flow.failed", "apiVersion": "2026-10", "createdAt": "2026-10-01T09:30:05+00:00", "test": false, "company": { "id": "0b3d6f2e-8a41-4c75-9e20-5f7a1c3d9b84", "name": "متجر الرياض" }, "data": { "flow": { "id": "e2a7c9d4-1b6f-4a38-9c50-8d3e7f2b1a96", "name": "تأكيد الطلب" }, "run": { "id": "b16d4f8a-2c9e-4b73-a5d1-0e8f3c7a9b25", "status": "failed", "endReason": "error", "triggerKind": "webhook", "startedAt": "2026-10-01T09:30:00+00:00", "endedAt": "2026-10-01T09:33:00+00:00", "variables": { "order_id": "10482", "confirmed": "نعم" } }, "conversation": { "id": "WhatsApp_3f6a2d18-0b7c-4e91-8a5d-c24e1f9b7a60", "channelId": "7c1e4b2a-9d3f-4e58-a6b1-2f0c8d7e5a13", "channelType": "whatsapp" }, "contact": { "id": "3f6a2d18-0b7c-4e91-8a5d-c24e1f9b7a60", "name": "نورة العتيبي", "phone": "+966501234567", "whatsAppId": "966501234567" } } } ``` ## Responses ### 410 The endpoint is gone for good: it is switched off at once, and the account's owner is told. ### 2XX Received. Answer within 10 seconds and do the work afterwards; the body is kept for your delivery log and otherwise ignored. ### default Anything else, a timeout or no answer is a failure, tried again after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h, then dead-lettered. An endpoint failing 100 times in a row, or for 3 days, is switched off. Tests are never retried. # lead.qualified Source: https://docs.revoplyai.com/webhooks/events/lead-qualified/ > A conversation became a qualified lead. Webhook event `lead.qualified`, delivered as `POST` to your endpoint. **Fires when** lead qualification scores a conversation as qualified for the first time. **Does not fire** when a lead that is already qualified changes score or temperature, or when a lead is created but not yet qualified. ## Parameters | Name | In | Required | Description | | --- | --- | --- | --- | | `X-Revoply-Signature` | header | yes | `t=,v1=`, with a second `v1` while a rotated secret still signs. Verify it before trusting the body. | | `X-Revoply-Event` | header | yes | The event's `type`, so a receiver can route before parsing. | | `X-Revoply-Delivery` | header | yes | This delivery's id. A resend of the same event keeps the envelope `id` and gets a new delivery id. | ## Payload ```json { "allOf": [ { "required": [ "id", "type", "apiVersion", "createdAt", "test", "company", "data" ], "type": "object", "properties": { "id": { "type": "string", "description": "The event's id, `evt_…`. The same on every retry and resend.", "example": "evt_4e1b9c2d7a3f4e8b9c0d1e2f3a4b5c6d" }, "type": { "type": "string", "description": "What happened. Treat an unknown value as an event you did not subscribe to, and acknowledge it.\n\nKnown values: `message.received`, `conversation.started`, `conversation.handover`, `conversation.assigned`, `conversation.resolved`, `contact.created`, `contact.updated`, `contact.opted_out`, `lead.qualified`, `appointment.booked`, `appointment.cancelled`, `flow.completed`, `flow.failed`, `ping`. More may be added.", "x-extensible-enum": [ "message.received", "conversation.started", "conversation.handover", "conversation.assigned", "conversation.resolved", "contact.created", "contact.updated", "contact.opted_out", "lead.qualified", "appointment.booked", "appointment.cancelled", "flow.completed", "flow.failed", "ping" ] }, "apiVersion": { "type": "string", "description": "The shape of `data`. A new version is opt-in per endpoint.", "example": "2026-10" }, "createdAt": { "type": "string", "description": "When it happened, UTC.", "format": "date-time" }, "test": { "type": "boolean", "description": "True for what \"Send test\" posts: a sample, about nobody real. Always present." }, "company": { "required": [ "id", "name" ], "type": "object", "properties": { "id": { "type": "string", "description": "The account (or project) it happened in.", "format": "uuid" }, "name": { "type": [ "null", "string" ] } } }, "data": { "type": "object", "description": "Depends on `type`; see each event." } }, "description": "Every event arrives in this envelope. Fields are only ever added, and a field that can be empty is sent as null rather than left out. Deduplicate on `id`: a delivery can arrive more than once." }, { "type": "object", "properties": { "type": { "const": "lead.qualified" }, "data": { "required": [ "lead", "conversation" ], "type": "object", "properties": { "lead": { "required": [ "id", "customerName", "status", "temperature", "score", "qualifiedAt" ], "type": "object", "properties": { "id": { "type": "string" }, "customerName": { "type": [ "null", "string" ] }, "status": { "type": "string", "description": "`qualified` on this event." }, "temperature": { "type": [ "null", "string" ], "description": "`cold`, `warm` or `hot`." }, "score": { "type": [ "null", "integer" ], "description": "0 to 100." }, "qualifiedAt": { "type": [ "null", "string" ], "format": "date-time" } } }, "conversation": { "required": [ "id", "channelId", "channelType" ], "type": [ "null", "object" ], "properties": { "id": { "type": "string", "description": "The conversation's id. Opaque; may contain `_`, `.`, `+`, `=` and `@`." }, "channelId": { "type": [ "null", "string" ], "description": "The channel (number, page, bot or widget) it is on." }, "channelType": { "type": [ "null", "string" ], "description": "`whatsapp`, `whatsapp_qr`, `telegram`, `messenger`, `instagram`, `web_widget`… Treat an unknown value as another channel.\n\nKnown values: `whatsapp`, `whatsapp_qr`, `telegram`, `messenger`, `instagram`, `web_widget`, `website`, `voice`. More may be added.", "x-extensible-enum": [ "whatsapp", "whatsapp_qr", "telegram", "messenger", "instagram", "web_widget", "website", "voice" ] } } } } } } } ] } ``` Example: ```json { "id": "evt_bc2246d8dc5862d6aa21fcb98cee0b24", "type": "lead.qualified", "apiVersion": "2026-10", "createdAt": "2026-10-01T09:30:05+00:00", "test": false, "company": { "id": "0b3d6f2e-8a41-4c75-9e20-5f7a1c3d9b84", "name": "متجر الرياض" }, "data": { "lead": { "id": "5d2b8e14-6a9f-4c37-b0e5-1f7d3a9c2e48", "customerName": "نورة العتيبي", "status": "qualified", "temperature": "hot", "score": 86, "qualifiedAt": "2026-10-01T09:30:00+00:00" }, "conversation": { "id": "WhatsApp_3f6a2d18-0b7c-4e91-8a5d-c24e1f9b7a60", "channelId": "7c1e4b2a-9d3f-4e58-a6b1-2f0c8d7e5a13", "channelType": "whatsapp" } } } ``` ## Responses ### 410 The endpoint is gone for good: it is switched off at once, and the account's owner is told. ### 2XX Received. Answer within 10 seconds and do the work afterwards; the body is kept for your delivery log and otherwise ignored. ### default Anything else, a timeout or no answer is a failure, tried again after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h, then dead-lettered. An endpoint failing 100 times in a row, or for 3 days, is switched off. Tests are never retried. # message.received Source: https://docs.revoplyai.com/webhooks/events/message-received/ > A customer wrote. Webhook event `message.received`, delivered as `POST` to your endpoint. **Fires when** a customer's message is saved to a conversation on any channel — WhatsApp (Cloud and QR), Telegram, Messenger, Instagram or the web widget — whoever is handling the conversation at the time. Once per message: a provider delivering the same message twice raises it once. Media arrives with its `type` (`image`, `audio`, `document`…) and any caption as `text`. **Does not fire** for anything the business sends: the assistant's replies, a flow's messages, your team's replies from the inbox, and the owner's replies from the WhatsApp Business app on a coexistence number. Not for the history WhatsApp imports when a coexistence number is connected. Not for delivery or read receipts. ## Parameters | Name | In | Required | Description | | --- | --- | --- | --- | | `X-Revoply-Signature` | header | yes | `t=,v1=`, with a second `v1` while a rotated secret still signs. Verify it before trusting the body. | | `X-Revoply-Event` | header | yes | The event's `type`, so a receiver can route before parsing. | | `X-Revoply-Delivery` | header | yes | This delivery's id. A resend of the same event keeps the envelope `id` and gets a new delivery id. | ## Payload ```json { "allOf": [ { "required": [ "id", "type", "apiVersion", "createdAt", "test", "company", "data" ], "type": "object", "properties": { "id": { "type": "string", "description": "The event's id, `evt_…`. The same on every retry and resend.", "example": "evt_4e1b9c2d7a3f4e8b9c0d1e2f3a4b5c6d" }, "type": { "type": "string", "description": "What happened. Treat an unknown value as an event you did not subscribe to, and acknowledge it.\n\nKnown values: `message.received`, `conversation.started`, `conversation.handover`, `conversation.assigned`, `conversation.resolved`, `contact.created`, `contact.updated`, `contact.opted_out`, `lead.qualified`, `appointment.booked`, `appointment.cancelled`, `flow.completed`, `flow.failed`, `ping`. More may be added.", "x-extensible-enum": [ "message.received", "conversation.started", "conversation.handover", "conversation.assigned", "conversation.resolved", "contact.created", "contact.updated", "contact.opted_out", "lead.qualified", "appointment.booked", "appointment.cancelled", "flow.completed", "flow.failed", "ping" ] }, "apiVersion": { "type": "string", "description": "The shape of `data`. A new version is opt-in per endpoint.", "example": "2026-10" }, "createdAt": { "type": "string", "description": "When it happened, UTC.", "format": "date-time" }, "test": { "type": "boolean", "description": "True for what \"Send test\" posts: a sample, about nobody real. Always present." }, "company": { "required": [ "id", "name" ], "type": "object", "properties": { "id": { "type": "string", "description": "The account (or project) it happened in.", "format": "uuid" }, "name": { "type": [ "null", "string" ] } } }, "data": { "type": "object", "description": "Depends on `type`; see each event." } }, "description": "Every event arrives in this envelope. Fields are only ever added, and a field that can be empty is sent as null rather than left out. Deduplicate on `id`: a delivery can arrive more than once." }, { "type": "object", "properties": { "type": { "const": "message.received" }, "data": { "required": [ "conversation", "contact", "message" ], "type": "object", "properties": { "conversation": { "required": [ "id", "channelId", "channelType" ], "type": "object", "properties": { "id": { "type": "string", "description": "The conversation's id. Opaque; may contain `_`, `.`, `+`, `=` and `@`." }, "channelId": { "type": [ "null", "string" ], "description": "The channel (number, page, bot or widget) it is on." }, "channelType": { "type": [ "null", "string" ], "description": "`whatsapp`, `whatsapp_qr`, `telegram`, `messenger`, `instagram`, `web_widget`… Treat an unknown value as another channel.\n\nKnown values: `whatsapp`, `whatsapp_qr`, `telegram`, `messenger`, `instagram`, `web_widget`, `website`, `voice`. More may be added.", "x-extensible-enum": [ "whatsapp", "whatsapp_qr", "telegram", "messenger", "instagram", "web_widget", "website", "voice" ] } } }, "contact": { "required": [ "id", "name", "phone", "whatsAppId" ], "type": [ "null", "object" ], "properties": { "id": { "type": [ "null", "string" ], "description": "The contact's id; null for a web widget visitor." }, "name": { "type": [ "null", "string" ] }, "phone": { "type": [ "null", "string" ], "description": "E.164, such as `+966501234567`, or null — never a WhatsApp user id." }, "whatsAppId": { "type": [ "null", "string" ], "description": "On WhatsApp, the id WhatsApp knows the customer by: the number without `+`, or a business-scoped user id when WhatsApp withholds the number. Null on other channels." } } }, "message": { "required": [ "id", "text", "type", "receivedAt" ], "type": "object", "properties": { "id": { "type": [ "null", "string" ] }, "text": { "type": [ "null", "string" ], "description": "The text, or a media message's caption; cut at 4,096 characters." }, "type": { "type": "string", "description": "`text`, `image`, `audio`, `video`, `document`, `sticker`, `location`, `contact`… Treat an unknown value as a message you cannot read." }, "receivedAt": { "type": "string", "format": "date-time" } } } } } } } ] } ``` Example: ```json { "id": "evt_5dabddce7c097ab703f21e8fdf0ab52b", "type": "message.received", "apiVersion": "2026-10", "createdAt": "2026-10-01T09:30:05+00:00", "test": false, "company": { "id": "0b3d6f2e-8a41-4c75-9e20-5f7a1c3d9b84", "name": "متجر الرياض" }, "data": { "conversation": { "id": "WhatsApp_3f6a2d18-0b7c-4e91-8a5d-c24e1f9b7a60", "channelId": "7c1e4b2a-9d3f-4e58-a6b1-2f0c8d7e5a13", "channelType": "whatsapp" }, "contact": { "id": "3f6a2d18-0b7c-4e91-8a5d-c24e1f9b7a60", "name": "نورة العتيبي", "phone": "+966501234567", "whatsAppId": "966501234567" }, "message": { "id": "m_wamid.HBgMOTY2NTAxMjM0NTY3FQIAEhggQTNFQjU2RkQ5RTcyOEIyRDQ1", "text": "السلام عليكم، متى يوصل طلبي رقم 10482؟", "type": "text", "receivedAt": "2026-10-01T09:30:00+00:00" } } } ``` ## Responses ### 410 The endpoint is gone for good: it is switched off at once, and the account's owner is told. ### 2XX Received. Answer within 10 seconds and do the work afterwards; the body is kept for your delivery log and otherwise ignored. ### default Anything else, a timeout or no answer is a failure, tried again after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h, then dead-lettered. An endpoint failing 100 times in a row, or for 3 days, is switched off. Tests are never retried. # ping Source: https://docs.revoplyai.com/webhooks/events/ping/ > A test from the dashboard. Webhook event `ping`, delivered as `POST` to your endpoint. **Fires when** someone presses "Send test" on the endpoint without choosing an event. Carries `"test": true`. **Does not fire** on its own: it cannot be subscribed to, is sent only when someone asks for it, and is never retried. ## Parameters | Name | In | Required | Description | | --- | --- | --- | --- | | `X-Revoply-Signature` | header | yes | `t=,v1=`, with a second `v1` while a rotated secret still signs. Verify it before trusting the body. | | `X-Revoply-Event` | header | yes | The event's `type`, so a receiver can route before parsing. | | `X-Revoply-Delivery` | header | yes | This delivery's id. A resend of the same event keeps the envelope `id` and gets a new delivery id. | ## Payload ```json { "allOf": [ { "required": [ "id", "type", "apiVersion", "createdAt", "test", "company", "data" ], "type": "object", "properties": { "id": { "type": "string", "description": "The event's id, `evt_…`. The same on every retry and resend.", "example": "evt_4e1b9c2d7a3f4e8b9c0d1e2f3a4b5c6d" }, "type": { "type": "string", "description": "What happened. Treat an unknown value as an event you did not subscribe to, and acknowledge it.\n\nKnown values: `message.received`, `conversation.started`, `conversation.handover`, `conversation.assigned`, `conversation.resolved`, `contact.created`, `contact.updated`, `contact.opted_out`, `lead.qualified`, `appointment.booked`, `appointment.cancelled`, `flow.completed`, `flow.failed`, `ping`. More may be added.", "x-extensible-enum": [ "message.received", "conversation.started", "conversation.handover", "conversation.assigned", "conversation.resolved", "contact.created", "contact.updated", "contact.opted_out", "lead.qualified", "appointment.booked", "appointment.cancelled", "flow.completed", "flow.failed", "ping" ] }, "apiVersion": { "type": "string", "description": "The shape of `data`. A new version is opt-in per endpoint.", "example": "2026-10" }, "createdAt": { "type": "string", "description": "When it happened, UTC.", "format": "date-time" }, "test": { "type": "boolean", "description": "True for what \"Send test\" posts: a sample, about nobody real. Always present." }, "company": { "required": [ "id", "name" ], "type": "object", "properties": { "id": { "type": "string", "description": "The account (or project) it happened in.", "format": "uuid" }, "name": { "type": [ "null", "string" ] } } }, "data": { "type": "object", "description": "Depends on `type`; see each event." } }, "description": "Every event arrives in this envelope. Fields are only ever added, and a field that can be empty is sent as null rather than left out. Deduplicate on `id`: a delivery can arrive more than once." }, { "type": "object", "properties": { "type": { "const": "ping" }, "data": { "required": [ "message" ], "type": "object", "properties": { "message": { "type": "string" } } } } } ] } ``` Example: ```json { "id": "evt_2f49fe013bfeb76d088b2b7018ef6f6b", "type": "ping", "apiVersion": "2026-10", "createdAt": "2026-10-01T09:30:05+00:00", "test": true, "company": { "id": "0b3d6f2e-8a41-4c75-9e20-5f7a1c3d9b84", "name": "متجر الرياض" }, "data": { "message": "This is a test event from RevoplyAI." } } ``` ## Responses ### 410 The endpoint is gone for good: it is switched off at once, and the account's owner is told. ### 2XX Received. Answer within 10 seconds and do the work afterwards; the body is kept for your delivery log and otherwise ignored. ### default Anything else, a timeout or no answer is a failure, tried again after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h, then dead-lettered. An endpoint failing 100 times in a row, or for 3 days, is switched off. Tests are never retried. # Events Source: https://docs.revoplyai.com/webhooks/events/ > Every event an endpoint can receive, what fires it and what it carries. An endpoint receives only the events it subscribes to. Each page below shows when the event fires and when it does not, every field of its `data`, and an example. All of them arrive in the same [envelope](/webhooks/#the-envelope). | Event | What happened | | ------------------------------------------------------------------ | --------------------------------------------- | | [`message.received`](/webhooks/events/message-received/) | A customer wrote. | | [`conversation.started`](/webhooks/events/conversation-started/) | A new conversation began. | | [`conversation.handover`](/webhooks/events/conversation-handover/) | The conversation was handed to the team. | | [`conversation.assigned`](/webhooks/events/conversation-assigned/) | The conversation was assigned, or unassigned. | | [`conversation.resolved`](/webhooks/events/conversation-resolved/) | The conversation was resolved or closed. | | [`contact.created`](/webhooks/events/contact-created/) | A contact was added. | | [`contact.updated`](/webhooks/events/contact-updated/) | A contact's details changed. | | [`contact.opted_out`](/webhooks/events/contact-opted-out/) | A contact opted out of messages. | | [`lead.qualified`](/webhooks/events/lead-qualified/) | A conversation became a qualified lead. | | [`appointment.booked`](/webhooks/events/appointment-booked/) | An appointment was booked. | | [`appointment.cancelled`](/webhooks/events/appointment-cancelled/) | An appointment was cancelled. | | [`flow.completed`](/webhooks/events/flow-completed/) | An automation finished. | | [`flow.failed`](/webhooks/events/flow-failed/) | An automation failed. | | [`ping`](/webhooks/events/ping/) | A test from the dashboard. | `ping` cannot be subscribed to: it is what **Send test** posts when no event is chosen. New event types may be added at any time; an endpoint never receives a type it did not subscribe to. If your receiver serves several endpoints, acknowledge a `type` it does not know with `2xx` and ignore it. # Webhooks Source: https://docs.revoplyai.com/webhooks/ > Events we post to your HTTPS endpoint as they happen, signed so you can verify them, delivered at least once. When something happens in your account (a customer writes, a conversation is handed to your team, an appointment is booked) we send a `POST` with a JSON body to every endpoint subscribed to that event. Webhooks are part of the Business plan; an Owner or Admin sets them up under **Integrations → Webhooks**. ## The envelope [#the-envelope] Every event has the same outer shape. `data` depends on `type`; each [event page](/webhooks/events/) documents it. ```json { "id": "evt_5dabddce7c097ab703f21e8fdf0ab52b", "type": "message.received", "apiVersion": "2026-10", "createdAt": "2026-10-01T09:30:05+00:00", "test": false, "company": { "id": "0b3d6f2e-8a41-4c75-9e20-5f7a1c3d9b84", "name": "متجر الرياض" }, "data": { "conversation": { "id": "WhatsApp_3f6a2d18-0b7c-4e91-8a5d-c24e1f9b7a60", "channelId": "7c1e4b2a-9d3f-4e58-a6b1-2f0c8d7e5a13", "channelType": "whatsapp" }, "contact": { "id": "3f6a2d18-0b7c-4e91-8a5d-c24e1f9b7a60", "name": "نورة العتيبي", "phone": "+966501234567", "whatsAppId": "966501234567" }, "message": { "id": "m_wamid.HBgMOTY2NTAxMjM0NTY3FQIAEhggQTNFQjU2RkQ5RTcyOEIyRDQ1", "text": "السلام عليكم، متى يوصل طلبي رقم 10482؟", "type": "text", "receivedAt": "2026-10-01T09:30:00+00:00" } } } ``` | Field | Type | Meaning | | -------------- | ---------------- | ------------------------------------------------------------------------------------- | | `id` | string | The event's id, `evt_…`. The same on every retry and resend: deduplicate on it. | | `type` | string | The event, such as `message.received`. Acknowledge types you do not know. | | `apiVersion` | string | The shape of `data`, currently `2026-10`. See [Versioning](/get-started/versioning/). | | `createdAt` | string | When it happened, UTC. | | `test` | boolean | `true` for what **Send test** posts: sample data about nobody real. Always present. | | `company.id` | string (UUID) | The account or [project](/get-started/introduction/#concepts) it happened in. | | `company.name` | string or `null` | That company's name. | | `data` | object | The event's details. | ## Headers [#headers] | Header | Value | | --------------------- | -------------------------------------------------------------------------------- | | `Content-Type` | `application/json; charset=utf-8` | | `User-Agent` | `RevoplyAI-Webhooks/1.0` | | `X-Revoply-Signature` | `t=,v1=`; see [Signatures](/webhooks/signatures/) | | `X-Revoply-Event` | The event's `type`, so you can route before parsing | | `X-Revoply-Delivery` | The delivery's id (a UUID), as the delivery log shows it; quote it to support | ## Delivery [#delivery] * **Answer with any `2xx` within 10 seconds.** Acknowledge first, then do the work: a receiver that holds the request open while it calls other systems will time out. Anything else (another status, a redirect, a timeout, a refused connection) is a failure and is [retried](/webhooks/retries-and-delivery-log/). * **At least once.** An event can arrive more than once, for example when your `2xx` was lost on the way back. Every delivery of it carries the same `id`; record the ids you have handled and skip repeats. * **No ordering guarantee.** Deliveries are sent in parallel and retried on a schedule, so a later event can arrive before an earlier one. Order by `createdAt`, and do not assume, for example, that `conversation.started` arrives before the first `message.received`. * **Verify before you trust.** Check the signature and its timestamp on every request; see [Verify webhook signatures](/guides/verify-webhook-signatures/). ## When nothing is sent [#when-nothing-is-sent] * **Switched-off endpoints** receive nothing; events that happen while an endpoint is off are not sent to it later. * **Outside the Business plan**, endpoints are kept but receive nothing until the account is back on it. * **A pause on our side.** We can pause all calls to merchants' systems, for example during an incident. The Webhooks page then shows a notice; events that happen during a pause are not sent later, and tests and resends are refused until it ends. ## Events [#events] | Event | What happened | | ------------------------------------------------------------------ | --------------------------------------------- | | [`message.received`](/webhooks/events/message-received/) | A customer wrote. | | [`conversation.started`](/webhooks/events/conversation-started/) | A new conversation began. | | [`conversation.handover`](/webhooks/events/conversation-handover/) | The conversation was handed to the team. | | [`conversation.assigned`](/webhooks/events/conversation-assigned/) | The conversation was assigned, or unassigned. | | [`conversation.resolved`](/webhooks/events/conversation-resolved/) | The conversation was resolved or closed. | | [`contact.created`](/webhooks/events/contact-created/) | A contact was added. | | [`contact.updated`](/webhooks/events/contact-updated/) | A contact's details changed. | | [`contact.opted_out`](/webhooks/events/contact-opted-out/) | A contact opted out of messages. | | [`lead.qualified`](/webhooks/events/lead-qualified/) | A conversation became a qualified lead. | | [`appointment.booked`](/webhooks/events/appointment-booked/) | An appointment was booked. | | [`appointment.cancelled`](/webhooks/events/appointment-cancelled/) | An appointment was cancelled. | | [`flow.completed`](/webhooks/events/flow-completed/) | An automation finished. | | [`flow.failed`](/webhooks/events/flow-failed/) | An automation failed. | | [`ping`](/webhooks/events/ping/) | A test from the dashboard. | # Retries and the delivery log Source: https://docs.revoplyai.com/webhooks/retries-and-delivery-log/ > What counts as a failed delivery, the retry schedule, when an endpoint is switched off, and how to read the log and resend. ## What counts as delivered [#what-counts-as-delivered] A delivery succeeds when your endpoint answers with any `2xx` status within 10 seconds. Everything else is a failure: * any other status, including redirects (`3xx`), which we do not follow; * no answer within 10 seconds; * a connection that cannot be made or breaks off, including DNS and TLS errors. ## Retry schedule [#retry-schedule] A failed delivery is tried again after each of these intervals, counted from the attempt that failed: | Attempt | After the previous attempt | | ------- | -------------------------- | | 1 | At once | | 2 | 1 minute | | 3 | 5 minutes | | 4 | 30 minutes | | 5 | 2 hours | | 6 | 6 hours | | 7 | 12 hours | | 8 | 24 hours | After the eighth failed attempt, about 45 hours after the first, the delivery is **not delivered** (dead-lettered) and is not tried again unless you resend it. A `410 Gone` answer is not retried: the delivery is dead-lettered at once and the endpoint is switched off (below). Tests are never retried. ## When we switch an endpoint off [#when-we-switch-an-endpoint-off] We switch an endpoint off, and email the account owner, when it: * answers `410 Gone`, which says the address no longer exists; or * fails 100 deliveries in a row; or * has been failing for 3 days, however few events it received. A successful delivery resets the count. Failed tests do not count. While an endpoint is off, new events are not sent to it, now or later, and deliveries still waiting for a retry are dead-lettered when they come due. Fix your receiver, send it a test, then switch it back on: its failure count starts over. ## The delivery log [#the-delivery-log] Each endpoint's **Delivery log** (**Integrations → Webhooks**) lists its deliveries from the last 30 days, newest first. A delivery shows its status, the number of attempts, the time of the last and next attempt, the body we sent, the status your endpoint answered and the first 2 KB of its answer. | Status | Meaning | | ------------- | ------------------------------------------- | | Waiting | Not attempted yet | | Delivered | Your endpoint answered `2xx` | | Retrying | Failed; another attempt is scheduled | | Not delivered | Dead-lettered; only a resend sends it again | Why an attempt failed: | Failure | Meaning | | ------------------- | --------------------------------------------------------------------------------------- | | `status` | Your endpoint answered with a status outside `2xx`. | | `timeout` | No answer within 10 seconds. | | `unreachable` | No connection: check the address, DNS, TLS, and that it is reachable from the internet. | | `not_allowed` | The address is not a public `https://` address. | | `secret_unreadable` | We could not read the endpoint's signing secret, so nothing was sent. Rotate it. | | `endpoint_off` | The endpoint was switched off when the delivery came due. | | `not_entitled` | The account was not on the Business plan when the delivery came due. | | `error` | Something failed on our side; the delivery is retried on the schedule. | When you contact support about a delivery, include its **Delivery ID** (the `X-Revoply-Delivery` header) and the event `id`. ## Resend [#resend] **Resend** on a delivery sends the same body again at once, with a fresh signature. The envelope `id` is unchanged, so a receiver that deduplicates on it acts on the event once. If the resend fails, the retry schedule starts again from the beginning. Resending needs the endpoint to be switched on and the Business plan. Tests and resends are limited to 10 a minute per account. # Setting up endpoints Source: https://docs.revoplyai.com/webhooks/setting-up-endpoints/ > Add a webhook endpoint in the dashboard, the rules its URL must meet, and what editing, switching off and deleting do. An endpoint is a URL your system listens on, with the events it receives and its own signing secret. An account can have up to 10. Only an Owner or an Admin can manage them. ## Add an endpoint [#add-an-endpoint] 1. In the dashboard, open **Integrations → Webhooks** and choose **Add endpoint**. 2. Enter the **Endpoint URL**, and optionally a **Name** (up to 80 characters) to tell endpoints apart. 3. Choose the **Events** it should receive. Several endpoints may receive the same event; each gets its own delivery. 4. Save. The endpoint's **signing secret** (`whsec_…`) is shown once. Copy it into your server's configuration now: afterwards only its last four characters are shown, and a lost secret can only be replaced by [rotating it](/get-started/security/#rotating-a-webhook-secret). 5. Choose **Send test** to check your receiver; see [Testing](/webhooks/testing/). ## URL rules [#url-rules] | Rule | Refused as | | -------------------------------------------------------------------------------------------------------------------------- | -------------------- | | A full, absolute URL whose host name contains a dot, such as `https://example.com/webhooks/revoply` (so not `localhost`) | Invalid address | | No username or password in it (`https://user:pass@…`) and no `#fragment` | Invalid address | | At most 500 characters | Too long | | `https://` only: events carry customers' names, numbers and messages | Not HTTPS | | Not `revoplyai.com` or any of its subdomains | One of our addresses | | A public host that resolves only to public IP addresses: no private ranges, and no names ending in `.local` or `.internal` | Unreachable | A query string is allowed, so URLs that carry their own token (as Zapier's and Make's do) work. Such a URL is a secret in its own right; we keep it out of our traces. Our own addresses are refused so that events cannot loop. To start a flow when something happens in RevoplyAI, use the flow's own triggers (a keyword, a new conversation, a tapped button) instead of pointing a webhook at a [flow trigger](/flow-triggers/) URL. ## Edit, switch off, delete [#edit-switch-off-delete] * **Edit** changes the name, URL or events. The URL rules are checked again. * **Switch off** stops deliveries at once. Events that happen while an endpoint is off are not sent to it later. You can still send it a test. * **Switch on** again when your receiver is fixed. Its count of failures starts over. * **Delete** stops deliveries at once and deletes the endpoint's delivery log. It cannot be undone. We switch an endpoint off ourselves when it answers `410 Gone`, or keeps failing; see [Retries and the delivery log](/webhooks/retries-and-delivery-log/#when-we-switch-an-endpoint-off). ## Without the Business plan [#without-the-business-plan] Endpoints are kept when an account leaves the Business plan, and can still be edited, switched off and deleted, but nothing is sent to them. Adding an endpoint, sending tests, rotating secrets and resending need the plan. # Signatures Source: https://docs.revoplyai.com/webhooks/signatures/ > How X-Revoply-Signature is computed and exactly how to verify it, with test vectors and verifiers in four languages. Every request we send carries an `X-Revoply-Signature` header. Verify it before you trust the body: it proves the request came from us, was not changed on the way, and is recent. ```http X-Revoply-Signature: t=1790847005,v1=3f96ce90286a8c1479a3ae099a34b3b2db87392c627ffbe8e8c7b3956aa878a0 ``` ## How it is computed [#how-it-is-computed] 1. `t` is the time of signing, in Unix seconds. 2. The signed payload is `t`, a full stop, and the request body exactly as sent (UTF-8 bytes): `1790847005.{"id":"evt_…",…}`. 3. `v1` is the HMAC-SHA256 of the signed payload, in lower-case hexadecimal. The key is the endpoint's **whole secret as a string, `whsec_` included**, as UTF-8 bytes. Do not decode the part after `whsec_`. While a rotated secret still signs (for 24 hours after a rotation), the header carries two `v1` values, one per secret: ```http X-Revoply-Signature: t=1790847005,v1=3f96ce90…,v1=20fd3acd… ``` ## How to verify it [#how-to-verify-it] 1. Read the **raw body** before any JSON parsing. A body that was parsed and serialised again is not the bytes we signed, even when it looks the same. See the [raw-body pitfalls](/guides/verify-webhook-signatures/#get-the-raw-body) for your framework. 2. Split the header on `,`, and each part on its first `=`. Take `t`, and every `v1`. Ignore keys you do not know: other schemes may be added. 3. Refuse the request if there is no `t` or no `v1`, or if `t` is more than **300 seconds** away from your clock in either direction. This stops a captured request being replayed later. Keep your server's clock synchronised. 4. Compute the HMAC-SHA256 of `{t}.{raw body}` with your secret. While you move to a rotated secret, compute it with both. 5. Accept the request if any computed value equals any `v1`. Compare in constant time. Answer a request that fails verification with `400` and do not act on it. It is recorded as a failed delivery and retried, which helps if the cause was a secret you had not deployed yet. ## Verifiers [#verifiers] Each of these passes every [test vector](#test-vectors). Copy it into your project as it is. Node.js Python PHP C# ```js // Verifies the X-Revoply-Signature header on a RevoplyAI webhook (Node.js 18+, no packages). // // X-Revoply-Signature: t=1790847005,v1=3f96ce90…[,v1=… while a rotated secret still signs] // // v1 is the hex HMAC-SHA256 of "{t}.{raw body}", keyed with the whole secret, whsec_ included. // Tested against openapi/webhook-signature-vectors.json by samples/webhooks/test/. import { createHmac, timingSafeEqual } from 'node:crypto'; /** How far the signed time may be from your clock before the request is refused. */ export const TOLERANCE_SECONDS = 300; /** * @param {Buffer | string} rawBody The body exactly as it arrived, before any JSON parsing. * @param {string | undefined} header The X-Revoply-Signature header. * @param {string | string[]} secrets Your whsec_… secret. While you switch to a rotated * secret, pass both. * @param {{ now?: number, toleranceSeconds?: number }} [options] `now` in Unix seconds. * @returns {boolean} */ export function verifyRevoplySignature(rawBody, header, secrets, options = {}) { if (typeof header !== 'string' || header.length === 0) return false; let timestamp; const signatures = []; for (const part of header.split(',')) { const index = part.indexOf('='); if (index === -1) continue; const key = part.slice(0, index).trim(); const value = part.slice(index + 1).trim(); if (key === 't' && /^\d+$/.test(value)) timestamp = Number(value); else if (key === 'v1') signatures.push(value); } if (timestamp === undefined || signatures.length === 0) return false; const now = options.now ?? Math.floor(Date.now() / 1000); const tolerance = options.toleranceSeconds ?? TOLERANCE_SECONDS; if (Math.abs(now - timestamp) > tolerance) return false; const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, 'utf8'); for (const secret of [secrets].flat()) { if (!secret) continue; const expected = Buffer.from( createHmac('sha256', secret).update(`${timestamp}.`).update(body).digest('hex'), ); for (const signature of signatures) { const given = Buffer.from(signature); if (given.length === expected.length && timingSafeEqual(given, expected)) return true; } } return false; } ``` ```python """Verifies the X-Revoply-Signature header on a RevoplyAI webhook (Python 3.9+, standard library). X-Revoply-Signature: t=1790847005,v1=3f96ce90...[,v1=... while a rotated secret still signs] v1 is the hex HMAC-SHA256 of "{t}.{raw body}", keyed with the whole secret, whsec_ included. Tested against openapi/webhook-signature-vectors.json by samples/webhooks/test/. """ import hashlib import hmac import time from typing import Iterable, Optional, Union # How far the signed time may be from your clock before the request is refused. TOLERANCE_SECONDS = 300 def verify_revoply_signature( raw_body: Union[bytes, str], header: Optional[str], secrets: Union[str, Iterable[str]], now: Optional[int] = None, tolerance_seconds: int = TOLERANCE_SECONDS, ) -> bool: """raw_body is the body exactly as it arrived. While you switch to a rotated secret, pass both secrets.""" if not header: return False timestamp = None signatures = [] for part in header.split(","): key, sep, value = part.partition("=") if not sep: continue key, value = key.strip(), value.strip() if key == "t" and value.isdigit(): timestamp = int(value) elif key == "v1": signatures.append(value) if timestamp is None or not signatures: return False current = int(time.time()) if now is None else now if abs(current - timestamp) > tolerance_seconds: return False body = raw_body if isinstance(raw_body, bytes) else raw_body.encode("utf-8") for secret in [secrets] if isinstance(secrets, str) else secrets: if not secret: continue expected = hmac.new( secret.encode("utf-8"), f"{timestamp}.".encode("ascii") + body, hashlib.sha256 ).hexdigest() if any(hmac.compare_digest(expected, signature) for signature in signatures): return True return False ``` ```php $toleranceSeconds) { return false; } foreach ((array) $secrets as $secret) { if ($secret === '') { continue; } $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); foreach ($signatures as $signature) { if (hash_equals($expected, $signature)) { return true; } } } return false; } ``` ```csharp // Verifies the X-Revoply-Signature header on a RevoplyAI webhook (.NET 8+, no packages). // // X-Revoply-Signature: t=1790847005,v1=3f96ce90…[,v1=… while a rotated secret still signs] // // v1 is the hex HMAC-SHA256 of "{t}.{raw body}", keyed with the whole secret, whsec_ included. // Tested against openapi/webhook-signature-vectors.json by samples/webhooks/test/. using System.Globalization; using System.Security.Cryptography; using System.Text; public static class RevoplySignature { /// How far the signed time may be from your clock before the request is refused. public const int ToleranceSeconds = 300; /// The body exactly as it arrived, before any JSON parsing. /// The X-Revoply-Signature header. /// Your whsec_… secret. While you switch to a rotated secret, pass both. /// Unix seconds; the current time when null. public static bool Verify( byte[] rawBody, string? header, IEnumerable secrets, long? now = null, int toleranceSeconds = ToleranceSeconds) { if (string.IsNullOrEmpty(header)) { return false; } long? timestamp = null; var signatures = new List(); foreach (var part in header.Split(',')) { var pair = part.Split('=', 2); if (pair.Length != 2) { continue; } var key = pair[0].Trim(); var value = pair[1].Trim(); if (key == "t" && long.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var t)) { timestamp = t; } else if (key == "v1") { signatures.Add(value); } } if (timestamp is not { } signedAt || signatures.Count == 0) { return false; } var current = now ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds(); if (Math.Abs(current - signedAt) > toleranceSeconds) { return false; } var prefix = Encoding.ASCII.GetBytes(signedAt.ToString(CultureInfo.InvariantCulture) + "."); foreach (var secret in secrets) { if (string.IsNullOrEmpty(secret)) { continue; } using var hmac = IncrementalHash.CreateHMAC(HashAlgorithmName.SHA256, Encoding.UTF8.GetBytes(secret)); hmac.AppendData(prefix); hmac.AppendData(rawBody); var expected = Encoding.ASCII.GetBytes(Convert.ToHexString(hmac.GetHashAndReset()).ToLowerInvariant()); foreach (var signature in signatures) { if (CryptographicOperations.FixedTimeEquals(expected, Encoding.ASCII.GetBytes(signature))) { return true; } } } return false; } } ``` For a whole receiver, see [Receive messages with webhooks](/guides/receive-messages-with-webhooks/). ## Test vectors [#test-vectors] [`webhook-signature-vectors.json`](/openapi/webhook-signature-vectors.json) holds signed requests generated by the code that signs our deliveries: an ASCII body, an Arabic body, a rotation with two `v1` values, and three that must fail (a changed body, a stale timestamp, the wrong secret). For each vector, verify `header` against `body` with each secret in `verifyWith`, taking `now` as the current time. The result must equal `valid`. ## Rotation [#rotation] When you rotate a secret, deliveries are signed with both the old and the new secret for 24 hours. Deploy the new secret within that time; during the switch, accept either. After a leak, rotate twice to retire the leaked secret at once. See [Security](/get-started/security/#rotating-a-webhook-secret). # Testing webhooks Source: https://docs.revoplyai.com/webhooks/testing/ > Send a ping or any event's example to an endpoint, tell tests from real events, and test a receiver on your computer. **Send test** on an endpoint (**Integrations → Webhooks**) posts one request to it now, signed with its current secret, and shows the body we sent and what your endpoint answered. ## Choose what to send [#choose-what-to-send] * **Ping** carries no customer data: it checks the address, your signature check and your answer. Its `type` is `ping` and its `data` is `{"message": "This is a test event from RevoplyAI."}`. * **Any event type** sends that event's documented example, the one on its [event page](/webhooks/events/), so you can build against the real shape before the real thing happens. The endpoint does not have to be subscribed to it. Every test carries `"test": true` in the envelope, and its own `evt_…` id. Real events always carry `"test": false`. Skip tests before they reach your CRM or trigger work: the example customer does not exist. ## How tests differ from real deliveries [#how-tests-differ-from-real-deliveries] * A test is attempted once and never retried. * A failed test does not count towards [switching the endpoint off](/webhooks/retries-and-delivery-log/#when-we-switch-an-endpoint-off). * You can test an endpoint that is switched off. * Tests appear in the endpoint's delivery log, and can be resent from there. * Tests and resends are limited to 10 a minute per account, and need the Business plan. ## Test a receiver on your computer [#test-a-receiver-on-your-computer] We deliver only to public `https://` addresses. Expose your local server with a tunnel, such as `ngrok http 3000`, add the HTTPS address it prints as an endpoint, and send a test. Delete the endpoint when you are done. See also [Testing safely](/get-started/testing-safely/). # Widget events Source: https://docs.revoplyai.com/widget/events/ > The events the website widget raises in your page, what each one carries, and how to listen for them. The widget tells your page what happens in the chat, for your analytics or your own UI. These are browser events on the visitor's page, not [webhooks](/webhooks/): they reach only that page, and only while it is open. ```js const off = RevoplyAI.on('first_message', () => { analytics.track('chat_started'); }); // later off(); ``` | Event | When | Handler receives | | -------------------- | ----------------------------------------------------- | -------------------------------------- | | `open` | The panel opens | Nothing | | `close` | The panel closes | Nothing | | `first_message` | The visitor sends the first message of a conversation | Nothing | | `message_sent` | The visitor sends a message | Nothing | | `reply_received` | Replies arrive in the panel | `{ count }`, the number of new replies | | `handoff_requested` | The visitor asks for a person | Nothing | | `conversation_ended` | The visitor ends the conversation | Nothing | | `feedback` | The visitor rates an answer | `{ helpful }`, `true` or `false` | | `error` | The panel hit an error | `{ message, … }`, for your logs | * Subscribe after `embed.js` has loaded; `on()` cannot be queued. * A handler that throws is logged to the console and does not stop the others. * New events may be added. Events you do not subscribe to cost nothing. To act on conversations on your server (store them, open a ticket), use the [`message.received`](/webhooks/events/message-received/) and [`conversation.handover`](/webhooks/events/conversation-handover/) webhooks instead: they are signed and do not depend on the visitor's browser. # Install the widget Source: https://docs.revoplyai.com/widget/ > Add the RevoplyAI chat widget to a website with one script tag, its attributes, or an iframe. The website widget is a chat panel on your own site, answered by your assistant and your team like any other channel (`channelType: web_widget`). It ships as two parts: * `embed.js`, a small loader that draws the launcher button and creates the panel when a visitor opens it; * the panel itself, loaded in an iframe from `widget.revoplyai.com` only when it is first needed. A visitor who never opens the chat downloads only the loader and one small configuration request. ## Script tag [#script-tag] Create a **Web widget** channel in the dashboard; its settings show the snippet with your channel id. Paste it before `` on every page that should show the chat: ```html ``` ### Attributes [#attributes] | Attribute | Default | Effect | | ------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------- | | `data-channel-id` | Required | The widget channel's id. | | `data-lang` | The browser's language | `en` or `ar`. Arabic also switches the panel to right-to-left. | | `data-position` | `bottom-right` | `bottom-right` or `bottom-left`. | | `data-color` | The channel's colour | The launcher's colour until the channel's settings have loaded. | | `data-auto-open` | Off | Milliseconds after which the panel opens by itself. | | `data-teaser` | On | `false` hides the greeting bubble next to the launcher. | | `data-teaser-delay` | `6000` | Milliseconds before the greeting bubble appears. | | `data-launcher` | Shown | `none` hides our button; open the panel from your own with the [JavaScript API](/widget/javascript-api/). | The brand colour, bot name, welcome message and suggested prompts come from the channel's settings in the dashboard; `data-color` only paints the launcher until they load. The launcher's position and auto-open are set by the attributes above alone. ## Iframe [#iframe] To place the panel yourself instead of using the launcher, frame it directly: ```html ``` The iframe has no launcher, no greeting bubble and no JavaScript API. Use it on sites whose Content-Security-Policy forbids inline styles; see [Security and CSP](/widget/security-and-csp/). ## Behaviour worth knowing [#behaviour-worth-knowing] * A conversation is created on the visitor's first message, not on page load, so browsing never creates empty conversations in your inbox. * A visitor's session lasts 72 hours from their last activity, on that browser. * If the panel was open, it stays open as the visitor moves between pages of your site. * Replies arriving while the panel is closed show an unread badge on the launcher. # JavaScript API Source: https://docs.revoplyai.com/widget/javascript-api/ > Open and close the widget from your page, tell it who the visitor is, and pass page context, with window.RevoplyAI. Once `embed.js` has run, `window.RevoplyAI` drives the widget from your own page. | Member | Does | | --------------------- | -------------------------------------------------------------------------------- | | `open()` | Opens the panel. | | `close()` | Closes it. | | `toggle()` | Opens it if closed, closes it if open. | | `isOpen()` | `true` while the panel is open. | | `identify(visitor)` | Attaches what you know about the visitor to the conversation. | | `setContext(context)` | Passes anything else the assistant should know about this page. | | `on(event, handler)` | Subscribes to an [event](/widget/events/); returns a function that unsubscribes. | | `off(event, handler)` | Unsubscribes a handler. | | `version` | The embed contract's version, such as `1.1.0`. | ## Open the panel from your own button [#open-the-panel-from-your-own-button] ```html ``` ## Tell it who the visitor is [#tell-it-who-the-visitor-is] ```js RevoplyAI.identify({ name: 'Sam Rivera', email: 'sam@example.com', attributes: { accountId: '4821', tier: 'gold' }, }); RevoplyAI.setContext({ pageUrl: location.href, cartValue: '129.00' }); ``` * `identify()` takes `name`, `email` and `attributes` (string values). Calling it again merges `attributes` with the earlier ones. * `setContext()` takes an object and merges it with earlier context. * Both can be called before the visitor opens the chat: the widget keeps the values and passes them on when the panel loads. These values come from the visitor's browser, so a visitor can change them. Use them to help the conversation, never as proof of who the visitor is. ## Calls made before the script has loaded [#calls-made-before-the-script-has-loaded] `embed.js` loads with `defer`, so `window.RevoplyAI` may not exist yet when your code runs. Until it does, queue calls on `window.RevoplyAI.q` as `[method, argument]` pairs; they run in order once the script loads. A small helper covers both cases: ```js function revoply(method, argument) { if (window.RevoplyAI && typeof window.RevoplyAI[method] === 'function') { return window.RevoplyAI[method](argument); } window.RevoplyAI = window.RevoplyAI || { q: [] }; window.RevoplyAI.q.push([method, argument]); } revoply('identify', { name: 'Sam Rivera' }); revoply('open'); ``` Only methods can be queued this way; subscribe to events after the script has loaded. # Security and CSP Source: https://docs.revoplyai.com/widget/security-and-csp/ > The Content-Security-Policy directives the widget needs, what it stores in the browser, and how to stop other sites using your channel. ## Content-Security-Policy [#content-security-policy] On a site with a Content-Security-Policy, allow the widget's script, frame and configuration request: ```text script-src https://widget.revoplyai.com; frame-src https://widget.revoplyai.com; connect-src https://api.revoplyai.com; ``` | Directive | Why | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `script-src` | `embed.js` itself. | | `frame-src` | The panel is an iframe on the widget's origin. | | `connect-src` | `embed.js` fetches the channel's settings so it can draw the launcher before the panel exists. The panel's own requests fall under the widget origin's policy, not yours. | The launcher's icons are SVG elements in your page, not images, so `img-src` needs nothing for the widget. `embed.js` adds one `