Skip to content
WOLE

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. 1

    Connect an endpoint

    In Settings, Integrations, Tools, choose Webhook and paste an https URL. Webhooks are on Growth and above.

  2. 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. 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 default

    A new conversation starts, on any channel. Off by default: it's the busiest event, and most chats never need your attention.

    Show 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 default

    A 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 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 default

    A teammate resolves a conversation. A chat the AI finished alone stays open, so it never sends this.

    Show 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 default

    A 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 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 default

    A 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 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 default

    A shopper who wrote in the chat checks out with that same cart. It arrives once Shopify sends WOLE the new order.

    Show 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 only

    You press Send test in Settings. It can't be switched off, and carries nothing about a shopper.

    Show 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.