For developers

Webhook reference

Memobell answers a business’s calls, texts and website chats. A webhook tells your system what came of them as it happens: Memobell sends an HTTPS POST with a JSON body to an address the business chooses. This page describes those requests. If you’d rather not write code, the same events work with Zapier and Make; see Integrations.

An owner or admin adds a webhook in Memobell under Settings, Connected tools: the address, which events to send, and on or off. Each webhook has its own signing secret, shown there. A business can have up to 10.

The request

  • POST to your address, which must be https:// and publicly reachable. Redirects aren’t followed.
  • The body is JSON (Content-Type: application/json), UTF-8.
  • Headers: Memobell-Event (the event type), Memobell-Event-Id (the same as id in the body), Memobell-Delivery (an id for this delivery, the same across its retries), Memobell-Signature (below) and User-Agent: Memobell-Webhooks/1.
  • Answer with any 2xx status within 10 seconds. The response body is ignored. Do slow work after you’ve answered.

Retries and failures

  • Anything other than a 2xx, a timeout or a failed connection is tried again: 8 tries in all, starting about 2 minutes apart and doubling, over a few hours.
  • Retries send the same body with the same id. A retry can arrive after you already handled the first try (if your answer was lost), so treat id as a key and ignore ones you’ve seen.
  • Events can arrive out of order. Use created_at if order matters to you.
  • If nothing gets through to a webhook for 3 days (at least 5 events in a row given up on), Memobell turns it off and emails the business owner. They can turn it back on in Settings.
  • Recent deliveries, with the status code your server gave, are listed in Settings for 30 days.

What every event looks like

FieldWhat it is
idThe event’s id, like evt_3f2a…. Stable across retries.
typeOne of: conversation.finished, message.taken, booking.created, booking.moved, booking.cancelled, booking.restored, contact.created.
created_atWhen it happened. ISO 8601, UTC.
business_idThe Memobell business (location) it happened at.
testtrue only for the sample sent by the “Send a test” button. Real events are false.
dataThe details, by event type (below). data.version is the version of that shape, currently 1.
  • Phone numbers are in E.164 (+15035550142). A number that couldn’t be made sense of is null, never a guess.
  • Times are ISO 8601 in UTC. Bookings also carry the business’s time_zone.
  • channel is voice, sms, whatsapp, web (website chat) or email (emails and website form messages). More may be added, so don’t fail on a value you don’t know.
  • Fields that don’t apply are present and null. New fields may be added to version 1 at any time; a field is only removed or changed in meaning with a new data.version.
  • Test calls and test chats never send events.

Checking the signature

Every request carries a header like Memobell-Signature: t=1794512642,v1=5f8a…. t is when it was sent (Unix seconds). v1 is an HMAC-SHA256, in hex, of t, a period, then the raw request body, keyed with the webhook’s signing secret (whsec_…).

To check a request: compute the same HMAC over `${t}.${rawBody}`, compare it with v1 in constant time, and refuse requests whose t is more than five minutes old. Use the body bytes exactly as received; re-serialized JSON won’t match. In Node:

import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody: the request body exactly as received (a string or Buffer), before any JSON parsing.
export function isFromMemobell(rawBody, signatureHeader, secret) {
  const parts = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=")));
  const timestamp = Number(parts.t);
  // Refuse anything older than five minutes, so a captured request can't be replayed later.
  if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
  const given = Buffer.from(parts.v1 ?? "", "hex");
  return given.length === 32 && timingSafeEqual(given, Buffer.from(expected, "hex"));
}

Making a new secret in Settings stops the old one working straight away.

Events

conversation.finished

A phone call ended and its summary is ready, or a text thread or website chat went quiet for 10 minutes.

A call sends this once. A text thread or chat sends it each time it goes quiet, so a customer who writes again the next day produces another event with the same conversation id. needs_attention is true when something is waiting on the team (a message to return, a text that couldn’t be sent). Robocalls the receptionist hung up on don’t send it.

{
  "id": "evt_11111111111111111111111111111111",
  "type": "conversation.finished",
  "created_at": "2026-11-12T19:44:02.000Z",
  "business_id": "6f0c2a8e-3f1b-4c53-9a36-0d6f3c1e7b21",
  "test": false,
  "data": {
    "version": 1,
    "conversation": {
      "id": "b7a1d2c4-5e6f-4a8b-9c0d-1e2f3a4b5c6d",
      "channel": "voice",
      "direction": "inbound",
      "started_at": "2026-11-12T19:40:31.000Z",
      "ended_at": "2026-11-12T19:43:12.000Z",
      "duration_seconds": 161,
      "summary": "Darnell Hughes has a blocked kitchen sink. He was quoted $150 and booked a visit for today at 3:30 PM.",
      "needs_attention": false,
      "urgent": false,
      "url": "https://memobell.com/b/6f0c2a8e-3f1b-4c53-9a36-0d6f3c1e7b21/calls/b7a1d2c4-5e6f-4a8b-9c0d-1e2f3a4b5c6d"
    },
    "customer": {
      "contact_id": "0e9d8c7b-6a5f-4e3d-8c2b-1a0f9e8d7c6b",
      "name": "Darnell Hughes",
      "phone": "+15035550142",
      "email": null
    }
  }
}

message.taken

The receptionist took a message for the team on a call, text or chat, or a customer sent their details through the intake form (source is then "form").

details holds the answers to the business’s own questions for that kind of caller, one per line. It arrives during the conversation, so it usually comes before conversation.finished.

{
  "id": "evt_22222222222222222222222222222222",
  "type": "message.taken",
  "created_at": "2026-11-12T21:05:47.000Z",
  "business_id": "6f0c2a8e-3f1b-4c53-9a36-0d6f3c1e7b21",
  "test": false,
  "data": {
    "version": 1,
    "message": {
      "id": "9a8b7c6d-5e4f-4a3b-8c1d-0e9f8a7b6c5d",
      "reason": "Water heater is leaking from the bottom",
      "details": "Address: 418 Alder Court\nHow old is the heater: about 12 years",
      "urgent": true,
      "caller_type": "New customer",
      "callback_number": "+15035550167",
      "source": "ai",
      "created_at": "2026-11-12T21:05:46.000Z"
    },
    "customer": {
      "contact_id": "7d6c5b4a-3f2e-4d1c-9b0a-8f7e6d5c4b3a",
      "name": "Keiko Tanaka",
      "phone": "+15035550167",
      "email": null
    },
    "conversation": {
      "id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
      "channel": "voice",
      "url": "https://memobell.com/b/6f0c2a8e-3f1b-4c53-9a36-0d6f3c1e7b21/calls/c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f"
    }
  }
}

booking.created

A booking was made, by the receptionist (booked_by is "ai") or by the team on the Bookings page ("team").

quote is the price worked out in the same conversation, if there was one; otherwise null. conversation is null for bookings the team added by hand.

{
  "id": "evt_33333333333333333333333333333333",
  "type": "booking.created",
  "created_at": "2026-11-12T19:42:10.000Z",
  "business_id": "6f0c2a8e-3f1b-4c53-9a36-0d6f3c1e7b21",
  "test": false,
  "data": {
    "version": 1,
    "booking": {
      "id": "4c3b2a19-0f8e-4d7c-b6a5-94837261f0e1",
      "status": "confirmed",
      "service": "Blocked kitchen drain",
      "type": "Service call",
      "starts_at": "2026-11-12T23:30:00.000Z",
      "ends_at": "2026-11-13T00:30:00.000Z",
      "time_zone": "America/Los_Angeles",
      "party_size": 1,
      "with": "Marco",
      "notes": "927 Bayview Terrace. Water sitting in the sink, no leak.",
      "booked_by": "ai",
      "created_at": "2026-11-12T19:42:10.000Z"
    },
    "customer": {
      "contact_id": "0e9d8c7b-6a5f-4e3d-8c2b-1a0f9e8d7c6b",
      "name": "Darnell Hughes",
      "phone": "+15035550142",
      "email": null
    },
    "conversation": {
      "id": "b7a1d2c4-5e6f-4a8b-9c0d-1e2f3a4b5c6d",
      "channel": "voice",
      "url": "https://memobell.com/b/6f0c2a8e-3f1b-4c53-9a36-0d6f3c1e7b21/calls/b7a1d2c4-5e6f-4a8b-9c0d-1e2f3a4b5c6d"
    },
    "quote": {
      "service": "Unblocking a drain",
      "price": 150,
      "low": null,
      "high": null,
      "currency": "USD",
      "summary": "Quoted $150 for Unblocking a drain"
    }
  }
}

booking.moved

A booking’s time changed.

Same shape as booking.created, plus booking.previous_starts_at.

{
  "id": "evt_44444444444444444444444444444444",
  "type": "booking.moved",
  "created_at": "2026-11-12T22:15:00.000Z",
  "business_id": "6f0c2a8e-3f1b-4c53-9a36-0d6f3c1e7b21",
  "test": false,
  "data": {
    "version": 1,
    "booking": {
      "id": "4c3b2a19-0f8e-4d7c-b6a5-94837261f0e1",
      "status": "confirmed",
      "service": "Blocked kitchen drain",
      "type": "Service call",
      "starts_at": "2026-11-13T16:00:00.000Z",
      "ends_at": "2026-11-13T17:00:00.000Z",
      "time_zone": "America/Los_Angeles",
      "party_size": 1,
      "with": "Marco",
      "notes": "927 Bayview Terrace. Water sitting in the sink, no leak.",
      "booked_by": "ai",
      "created_at": "2026-11-12T19:42:10.000Z",
      "previous_starts_at": "2026-11-12T23:30:00.000Z"
    },
    "customer": {
      "contact_id": "0e9d8c7b-6a5f-4e3d-8c2b-1a0f9e8d7c6b",
      "name": "Darnell Hughes",
      "phone": "+15035550142",
      "email": null
    },
    "conversation": {
      "id": "b7a1d2c4-5e6f-4a8b-9c0d-1e2f3a4b5c6d",
      "channel": "voice",
      "url": "https://memobell.com/b/6f0c2a8e-3f1b-4c53-9a36-0d6f3c1e7b21/calls/b7a1d2c4-5e6f-4a8b-9c0d-1e2f3a4b5c6d"
    },
    "quote": {
      "service": "Unblocking a drain",
      "price": 150,
      "low": null,
      "high": null,
      "currency": "USD",
      "summary": "Quoted $150 for Unblocking a drain"
    }
  }
}

booking.cancelled

A booking was cancelled.

Same shape as booking.created, with booking.status set to "cancelled".

booking.restored

A cancelled booking was put back at its original time.

Same shape as booking.created.

contact.created

Someone the business hasn’t heard from before got in touch, or the team added a customer.

A first-time caller is often only a phone number at this point. Their name follows in message.taken, booking.created or conversation.finished, under customer, with the same contact_id.

{
  "id": "evt_77777777777777777777777777777777",
  "type": "contact.created",
  "created_at": "2026-11-12T19:40:31.000Z",
  "business_id": "6f0c2a8e-3f1b-4c53-9a36-0d6f3c1e7b21",
  "test": false,
  "data": {
    "version": 1,
    "contact": {
      "id": "0e9d8c7b-6a5f-4e3d-8c2b-1a0f9e8d7c6b",
      "name": null,
      "phone": "+15035550142",
      "email": null,
      "created_at": "2026-11-12T19:40:31.000Z"
    }
  }
}

Trying it out

“Send a test” next to a webhook in Settings sends a sample message.taken with test set to true and made-up details, once, and shows whether your server accepted it. It works whether the webhook is on or off.