Developer docs
WOLE webhooks
What WOLE sends to your endpoint, how to check it came from WOLE, and what happens when your endpoint is down. Signed per Standard Webhooks, so its libraries work too.
Set up
A store owner, or a teammate allowed to manage channels, connects the endpoint.
- 1
Connect an endpoint
In Settings, Integrations, Tools, choose Webhook and paste an https URL. Webhooks are on Growth and above.
- 2
Pick events, keep the secret
Every event but conversation.created is on to start. The signing secret starts with whsec_ and is shown once, so store it.
- 3
Send test
WOLE sends a test.ping and shows your status code and response time. Every delivery lands in the log, with Retry.
Every request
A POST with a JSON body and three Standard Webhooks headers.
Headers
- webhook-id
- The event's id, the same as the envelope's id. It stays the same on every retry, so dedupe on it.
- webhook-timestamp
- When this attempt was sent, in Unix seconds. It is part of what's signed.
- webhook-signature
- v1, a comma, then the base64 HMAC-SHA256 of id.timestamp.body. Several entries may come separated by spaces; accept the request if any one matches.
- content-type
- application/json
- user-agent
- WOLE-Webhooks/1
The envelope
Every event has the same outer shape: its id, type, version (1), occurredAt, the shop and the event's data.
{
"id": "4f8c3e1a-7b2d-4d6e-9a5f-1c3b7e9d2a60",
"type": "test.ping",
"version": 1,
"occurredAt": "2026-10-18T08:55:00.000Z",
"shop": "acme.myshopify.com",
"data": {
"message": "This is a test from WOLE."
}
}Verify the signature
Sign the raw body the way WOLE does and compare. Refuse a timestamp more than 5 minutes from your clock, and skip a webhook-id you've already handled.
Node, with node:crypto
The key is the bytes your secret encodes: the base64 after whsec_. The signed text is webhook-id, a dot, webhook-timestamp, a dot, and the body.
import { createHmac, timingSafeEqual } from "node:crypto";
// `body` is the raw request body, exactly as received: verify before JSON.parse.
export function verifyWoleWebhook(secret, headers, body) {
const id = headers["webhook-id"];
const timestamp = Number(headers["webhook-timestamp"]);
const signatures = headers["webhook-signature"];
if (!id || !Number.isInteger(timestamp) || !signatures) return false;
// more than 5 minutes off is a replay, or a clock to fix
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
// the key is the base64 after "whsec_"
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest();
// a space-separated list of "v1,<base64>"; any one may match
return signatures.split(" ").some((entry) => {
const [version, value = ""] = entry.split(",");
const given = Buffer.from(value, "base64");
if (version !== "v1" || given.length !== expected.length) return false;
return timingSafeEqual(given, expected);
});
}With Express
import express from "express";
import { verifyWoleWebhook } from "./verify-wole-webhook.js";
const app = express();
// the raw body: parsing and re-serializing the JSON breaks the signature
app.post("/hooks/wole", express.raw({ type: "application/json" }), (req, res) => {
const body = req.body.toString("utf8");
if (!verifyWoleWebhook(process.env.WOLE_WEBHOOK_SECRET, req.headers, body)) {
return res.sendStatus(401);
}
const event = JSON.parse(body);
// skip event.id if you've seen it, queue the work, then answer
res.sendStatus(200);
});Or a Standard Webhooks library
WOLE follows the Standard Webhooks spec, so its libraries for Node, Python, Go, Ruby, PHP and more verify WOLE's requests as they are.
import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.WOLE_WEBHOOK_SECRET);
// throws when the signature or timestamp is wrong; returns the parsed event
const event = wh.verify(body, req.headers);Events
Choose which ones your endpoint receives. Each example is the full body WOLE sends.
conversation.created
Off by defaultA new conversation starts, on any channel. Off by default: it's the busiest event, and most chats never need your attention.
Show the exampleHide the example
{ "id": "0d3c1f6a-9e2b-4a7d-8c5e-1b6f4a2d9e03", "type": "conversation.created", "version": 1, "occurredAt": "2026-10-18T09:02:11.480Z", "shop": "acme.myshopify.com", "data": { "conversation": { "id": "5f0c7a2e-3b1d-4c8e-9a6f-2d4b8e1c7a90", "channel": "WIDGET", "status": "AI_HANDLED", "subject": null, "startedAt": "2026-10-18T09:02:11.480Z", "resolvedAt": null, "inboxUrl": "https://admin.shopify.com/store/acme/apps/wole-ai-assistant/app/inbox?c=5f0c7a2e-3b1d-4c8e-9a6f-2d4b8e1c7a90", "webInboxUrl": null }, "shopper": { "id": "c41e9b07-8d2a-4f5e-b1c3-6a7d9e0f2b48", "name": "Mai Tran", "email": "mai@example.com", "emailSource": "SHOPPER", "phone": null, "locale": "en", "shopifyCustomerId": null, "label": "Mai Tran" } } }conversation.handed_off
On by defaultA conversation is handed to your team: the shopper asked for a person, or a handoff rule matched. A teammate picking a chat up on their own doesn't send it.
Show the exampleHide the example
{ "id": "7a2e5c9d-1f4b-4e8a-b3d6-9c0e2f5a8b14", "type": "conversation.handed_off", "version": 1, "occurredAt": "2026-10-18T09:02:15.117Z", "shop": "acme.myshopify.com", "data": { "conversation": { "id": "5f0c7a2e-3b1d-4c8e-9a6f-2d4b8e1c7a90", "channel": "WIDGET", "status": "WAITING_ON_HUMAN", "subject": null, "startedAt": "2026-10-18T09:02:11.480Z", "resolvedAt": null, "inboxUrl": "https://admin.shopify.com/store/acme/apps/wole-ai-assistant/app/inbox?c=5f0c7a2e-3b1d-4c8e-9a6f-2d4b8e1c7a90", "webInboxUrl": null }, "shopper": { "id": "c41e9b07-8d2a-4f5e-b1c3-6a7d9e0f2b48", "name": "Mai Tran", "email": "mai@example.com", "emailSource": "SHOPPER", "phone": null, "locale": "en", "shopifyCustomerId": null, "label": "Mai Tran" }, "handoff": { "cause": "shopper_request", "ruleKind": null }, "transcript": [ { "author": "shopper", "name": null, "text": "My parcel says delivered but it isn't here.", "sentAt": "2026-10-18T09:02:11.480Z", "attachments": [] }, { "author": "assistant", "name": null, "text": "I'm sorry it hasn't turned up. I'll pass this to the team so someone can look into it.", "sentAt": "2026-10-18T09:02:14.902Z", "attachments": [] } ], "transcriptTruncated": false } }conversation.resolved
On by defaultA teammate resolves a conversation. A chat the AI finished alone stays open, so it never sends this.
Show the exampleHide the example
{ "id": "b8f1d4a7-6c3e-4b9d-a2f5-0e7c1d8b3a25", "type": "conversation.resolved", "version": 1, "occurredAt": "2026-10-18T09:31:40.006Z", "shop": "acme.myshopify.com", "data": { "conversation": { "id": "5f0c7a2e-3b1d-4c8e-9a6f-2d4b8e1c7a90", "channel": "WIDGET", "status": "RESOLVED", "subject": null, "startedAt": "2026-10-18T09:02:11.480Z", "resolvedAt": "2026-10-18T09:31:40.006Z", "inboxUrl": "https://admin.shopify.com/store/acme/apps/wole-ai-assistant/app/inbox?c=5f0c7a2e-3b1d-4c8e-9a6f-2d4b8e1c7a90", "webInboxUrl": null, "rating": null }, "shopper": { "id": "c41e9b07-8d2a-4f5e-b1c3-6a7d9e0f2b48", "name": "Mai Tran", "email": "mai@example.com", "emailSource": "SHOPPER", "phone": null, "locale": "en", "shopifyCustomerId": null, "label": "Mai Tran" }, "transcript": [ { "author": "shopper", "name": null, "text": "My parcel says delivered but it isn't here.", "sentAt": "2026-10-18T09:02:11.480Z", "attachments": [] }, { "author": "assistant", "name": null, "text": "I'm sorry it hasn't turned up. I'll pass this to the team so someone can look into it.", "sentAt": "2026-10-18T09:02:14.902Z", "attachments": [] }, { "author": "teammate", "name": "Linh", "text": "Hi Mai, the carrier has confirmed it went to a neighbour. I've sent you their note by email.", "sentAt": "2026-10-18T09:30:58.214Z", "attachments": [] } ], "transcriptTruncated": false } }contact.captured
On by defaultA shopper types their email into the chat's contact card. An email WOLE learns from Shopify or from an order doesn't send it.
Show the exampleHide the example
{ "id": "e4c7a1b9-2d5f-4a8e-9b3c-6f0d2e7a1c36", "type": "contact.captured", "version": 1, "occurredAt": "2026-10-18T09:02:40.553Z", "shop": "acme.myshopify.com", "data": { "shopper": { "id": "c41e9b07-8d2a-4f5e-b1c3-6a7d9e0f2b48", "name": "Mai Tran", "email": "mai@example.com", "emailSource": "SHOPPER", "phone": null, "locale": "en", "shopifyCustomerId": null, "label": "Mai Tran" }, "conversationId": "5f0c7a2e-3b1d-4c8e-9a6f-2d4b8e1c7a90", "contact": { "email": "mai@example.com", "name": "Mai Tran", "source": "chat" } } }rating.submitted
On by defaultA shopper rates a conversation. It can arrive twice: once for the stars, then again with isUpdate true when a comment follows. Upsert by conversation.id.
Show the exampleHide the example
{ "id": "2b9e6d3f-8a1c-4f7b-a5d2-3e8c0b6f9a47", "type": "rating.submitted", "version": 1, "occurredAt": "2026-10-18T09:32:05.271Z", "shop": "acme.myshopify.com", "data": { "conversation": { "id": "5f0c7a2e-3b1d-4c8e-9a6f-2d4b8e1c7a90", "channel": "WIDGET", "status": "RESOLVED", "subject": null, "startedAt": "2026-10-18T09:02:11.480Z", "resolvedAt": "2026-10-18T09:31:40.006Z", "inboxUrl": "https://admin.shopify.com/store/acme/apps/wole-ai-assistant/app/inbox?c=5f0c7a2e-3b1d-4c8e-9a6f-2d4b8e1c7a90", "webInboxUrl": null }, "shopper": { "id": "c41e9b07-8d2a-4f5e-b1c3-6a7d9e0f2b48", "name": "Mai Tran", "email": "mai@example.com", "emailSource": "SHOPPER", "phone": null, "locale": "en", "shopifyCustomerId": null, "label": "Mai Tran" }, "rating": { "stars": 5, "comment": null, "ratedAt": "2026-10-18T09:32:05.271Z", "isUpdate": false } } }order.attributed
On by defaultA shopper who wrote in the chat checks out with that same cart. It arrives once Shopify sends WOLE the new order.
Show the exampleHide the example
{ "id": "9d4a2f7c-5e1b-4c9a-8f3d-7b2e6a0c4d58", "type": "order.attributed", "version": 1, "occurredAt": "2026-10-18T10:14:52.830Z", "shop": "acme.myshopify.com", "data": { "conversation": { "id": "5f0c7a2e-3b1d-4c8e-9a6f-2d4b8e1c7a90", "channel": "WIDGET", "status": "AI_HANDLED", "subject": null, "startedAt": "2026-10-18T09:02:11.480Z", "resolvedAt": null, "inboxUrl": "https://admin.shopify.com/store/acme/apps/wole-ai-assistant/app/inbox?c=5f0c7a2e-3b1d-4c8e-9a6f-2d4b8e1c7a90", "webInboxUrl": null }, "shopper": { "id": "c41e9b07-8d2a-4f5e-b1c3-6a7d9e0f2b48", "name": "Mai Tran", "email": "mai@example.com", "emailSource": "SHOPPER", "phone": null, "locale": "en", "shopifyCustomerId": null, "label": "Mai Tran" }, "order": { "id": "6123456789012", "name": "#1042", "total": "129.90", "currency": "USD", "placedAt": "2026-10-18T10:14:50.000Z" } } }test.ping
Send test onlyYou press Send test in Settings. It can't be switched off, and carries nothing about a shopper.
Show the exampleHide the example
{ "id": "4f8c3e1a-7b2d-4d6e-9a5f-1c3b7e9d2a60", "type": "test.ping", "version": 1, "occurredAt": "2026-10-18T08:55:00.000Z", "shop": "acme.myshopify.com", "data": { "message": "This is a test from WOLE." } }
Inside data
The objects that repeat across events.
- conversation
- id, channel (WIDGET or EMAIL today), status (AI_HANDLED, WAITING_ON_HUMAN, WITH_HUMAN, RESOLVED or BLOCKED), subject (an email's subject, else null), startedAt, resolvedAt, and inboxUrl and webInboxUrl, links into WOLE's inbox that are null when unavailable.
- shopper
- id, name, email, emailSource (SHOPIFY, ORDER, SHOPPER or SENDER), phone, locale, shopifyCustomerId, and label: the name, else “Visitor #1042”.
- transcript
- Oldest first, the newest 100 messages within 50,000 characters. Each has author (shopper, assistant or teammate), name (a teammate's only), text, sentAt and attachments. transcriptTruncated says when older ones were left out.
- handoff
- cause is shopper_request, rule, custom_rule, no_answer, out_of_credits, channel_ai_off, reply_cap or channel_not_in_plan. ruleKind names the built-in rule when cause is rule, else null.
- order
- id and name are Shopify's. total is a decimal string such as "129.90", never a number, with its currency.
- Times
- Every time is an ISO 8601 string in UTC.
Delivery and retries
What WOLE expects from your endpoint, and what it does when it doesn't get it.
- Answer fast
- Reply with any 2xx within 10 seconds, and do the slow work after you've answered.
- Retries
- Any other status, a timeout or a network error is retried about 30 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 24 hours after the try before, each give or take 20%: 8 attempts over about 42 hours.
- Retry-After
- A Retry-After header on your response sets the next try instead, up to 1 hour away.
- Redirects
- Not followed. A 3xx counts as a failure, so use the final URL.
- After the last attempt
- The delivery stops and stays in the log, where Retry sends it again.
- Turning off
- After 15 failed attempts in a row that span more than a day, WOLE turns the webhook off, cancels what's waiting and emails the store owner. Events from while it's off are never sent.
- Your response
- WOLE reads up to 4 KB of it and keeps the first 500 characters in the log.
- Your URL
- https, up to 2,048 characters, with no username or password, on a public host. The host is checked when you save and before every send.
Good to know
- Ignore unknown fields
- Fields may be added at any time. A change that removes, renames or retypes one would come with a new version number.
- Order isn't guaranteed
- A retry can land after a later event. Use occurredAt to put events in order.
- At least once
- The same event can arrive more than once. Skip a webhook-id you've already handled.
- Ratings can arrive twice
- Once for the stars, then with isUpdate true when the shopper adds a comment. Upsert by conversation.id.
- A new secret
- Generate a new secret takes effect at once, so switch your endpoint to it straight away.
- One endpoint per store
- Each store connects one webhook URL, with its own events and secret.
Once an event reaches your endpoint, that copy is yours to govern. Deleting a customer in Shopify removes them from WOLE, not from what your endpoint already stored.
Connect your own endpoint
Install WOLE free from the Shopify App Store. Webhooks are on Growth and above, with Klaviyo and Gorgias.