developers
ES Sign up

Guides

Webhooks

heysteff calls your server over HTTP when something happens in your workspace: a new message, an escalation, a payment. Every delivery is signed so you know it came from heysteff.

Set up a webhook#

In Settings → Developers → Outbound webhooks, create a webhook with your HTTPS URL and pick the events. A webhook with no events selected receives all of them. It shows you a secret when you create it: keep it, it is the key to verify signatures. Send test event delivers a webhook.test signed like every other event.

Also through the API, with a key that has the webhooks:write scope: POST /v1/webhooks creates one and returns the secret in secret only once; PATCH /v1/webhooks/{id} changes the events or pauses it (is_active: false); DELETE /v1/webhooks/{id} deletes it. A test key gets 403 test_key_not_allowed. With the CLI: heysteff webhooks create --url … --event ….

curl
curl https://app.heysteff.com/api/v1/webhooks \
  -H "Authorization: Bearer $HEYSTEFF_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.tutienda.com/heysteff/webhooks",
    "events": ["message:received", "message.delivered", "message.read", "message.failed"]
  }'

Events#

EventWhen it fires
message:receivedA customer wrote on any channel.
message:sentA message went out to a customer.
conversation.escalatedA conversation was handed to a team member.
message.deliveredA message you sent reached the customer's phone (WhatsApp, Instagram, Messenger, SMS). Once per message.
message.readThe customer read your message (WhatsApp, Instagram, Messenger). Once per message.
channel.connectedA channel was connected or reconnected in your workspace, by any path (the console, the dashboard, the MCP or a setup link). data: channel_connection_id, channel_type, display_name, developer_customer_id (or null) and reconnected.
customer.channel_connectedOne of your customers finished connecting a channel with their setup link. See Customers and setup links.
call.startedA WhatsApp call started: it is ringing or was already answered. See WhatsApp calls.
call.endedA call that someone answered ended (with duration_seconds) or it failed.
call.missedA call ended with nobody answering, or it was rejected.
message.failedA message could not be delivered.
agent.replied, agent.handoff, agent.failedThe AI agent replied, handed the conversation to a person or could not reply. See AI agent.
insight.detectedAn analysis of your conversations found something to review. See AI agent.
conversation.urgentAn escalation was flagged as high urgency.
flow.completedA structured capture finished (a review, for example).
cart.abandoned, cart.recoveredA cart was abandoned or recovered.
purchase.completedA purchase was recorded.
payment.paid, payment.failed, payment.refundedA charge changed status.
campaign.completedA campaign run finished.
channel.errorA channel has a problem that needs attention.
conversation.createdA conversation was created, by any path: a customer wrote for the first time, you opened it through the API, a campaign or a flow.
conversation.inactiveAn open conversation has gone inactivity_hours without messages (per webhook, 1 to 720, default 24). Arrives once per silence.
conversation.endedThe conversation was closed. close_kind says how: resolved, archived or auto_archived.
conversation.assignedThe person in charge changed (assigned_user_id and previous_assigned_user_id).
conversation.bot_toggledThe bot was paused or resumed (bot_enabled, paused_by).
contact.updatedA contact's name, phone, email or channel ids changed. changes has the before and after.
contact.opted_out, contact.opted_inThe contact unsubscribed on a channel (for example, replied "STOP") or accepted messages again.
channel.disconnected, channel.reconnectedA channel stopped working (it was disconnected or the provider said it left), or the provider said it came back. If you connect it again yourself, you get channel.connected.
channel.restrictedMeta restricted, sanctioned or disabled the WhatsApp account. restriction has what Meta reported.
webhook.pausedAnother webhook in the workspace was paused for failing too much (see Automatic pause).
webhook.testYou sent it with Send test event, to try your server. It reaches that webhook even if it is not subscribed to any event.

message.delivered, message.read and message.failed include message_id, conversation_id, channel_type, external_message_id, status and timestamp; message.failed adds error: { code, message }. You get one per status change: a repeated receipt from the provider isn't sent again. message:received includes interactive_reply: { id, title } when the customer taps a button or a list option, and flow_response: { flow_token, data } when they submit a WhatsApp Flow (on other messages, both are null).

call.started, call.ended and call.missed include call_id (the id from GET /v1/calls), wa_call_id, channel_connection_id, conversation_id, contact_id, direction (inbound or outbound), status, customer (the customer's phone), started_at, ended_at, duration_seconds and error. For them to arrive, the number's Meta app must be subscribed to the calls webhook field.

The full event list shows up when you create the webhook in the app.

Delivery body#

A POST with Content-Type: application/json and this envelope. Event specific fields travel in data:

JSON
{
  "id": "5d1f0c7e-8a2b-4c3d-9e4f-0a1b2c3d4e5f",
  "tenant_id": "2c7e9b1a-3d4f-4a5b-8c6d-7e8f9a0b1c2d",
  "event": "message:received",
  "ts": "2026-10-31T14:03:52.104Z",
  "data": {
    "conversation_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
    "message_id": "4e3d2c1b-0a9f-4e8d-7c6b-5a4f3e2d1c0b",
    "channel_type": "whatsapp_cloud",
    "channel": "whatsapp_cloud",
    "channel_connection_id": "3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d",
    "external_user_id": "56912345678",
    "bot_paused": false,
    "bot_paused_by": null,
    "sandbox": false,
    "content": "Thanks! What time will it arrive?",
    "content_type": "text",
    "contact_id": "7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918",
    "media_url": null,
    "filename": null
  }
}

On message:received and message:sent, data also carries what you need to answer and to know whether to speak (see Bring your own AI):

FieldWhat it is
channelThe conversation channel. Pass it as-is as channel of POST /v1/messages (same as channel_type).
channel_connection_idThe number or account the conversation runs through, or null.
external_user_idWho to answer: pass it as to of POST /v1/messages.
contact_idThe conversation contact, or null.
bot_pausedtrue when a person or a flow has the conversation: your AI should not answer.
bot_paused_byWho paused it (api_escalation, api, human_agent, flow:<id>…), or null when not paused.
sandboxtrue for a sandbox conversation.
HeaderContent
X-Heysteff-EventEvent name.
X-Heysteff-TimestampUnix seconds when the delivery was signed.
X-Heysteff-Signaturev1= followed by the hex HMAC-SHA256.
X-Heysteff-Delivery-IdId of the delivery to this webhook. It repeats on its retries.
X-Heysteff-Idempotency-KeyStable event id (equal to the envelope id): the same on every retry and on every webhook that receives the same event. Use it to dedupe.
X-Heysteff-Batch, X-Heysteff-Batch-SizeOnly on a batched POST: true and how many events events carries (see Batch quick messages).

Meta format. If you already have a receiver built for WhatsApp Cloud API webhooks, create the webhook with format: "meta": it gets Meta's payload (object: "whatsapp_business_account") signed with X-Hub-Signature-256, for WhatsApp Cloud message events only. See Compatible with Meta.

Verify the signature#

The signature is an HMAC-SHA256 with the webhook secret over the string ${timestamp}.${body}, where timestamp is the X-Heysteff-Timestamp header and body is the raw body exactly as received. To accept a delivery:

  1. Read the raw body, without parsing or re-serializing it.
  2. Reject the delivery if the timestamp is more than 300 seconds away from your clock.
  3. Compute v1= + HMAC and compare it with the header in constant time.
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SECRET = process.env.HEYSTEFF_WEBHOOK_SECRET;

// The signature covers the RAW body: do not parse it before verifying.
app.post('/webhooks/heysteff', express.raw({ type: 'application/json' }), (req, res) => {
  const timestamp = req.get('X-Heysteff-Timestamp') ?? '';
  const signature = req.get('X-Heysteff-Signature') ?? '';
  const body = req.body.toString('utf8');

  // Reject deliveries older than 5 minutes (or from the future) to stop replays.
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(age) || age > 300) return res.status(400).send('stale timestamp');

  const expected = 'v1=' + crypto
    .createHmac('sha256', SECRET)
    .update(`${timestamp}.${body}`)
    .digest('hex');

  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(401).send('invalid signature');
  }

  const event = JSON.parse(body);
  // Dedupe on X-Heysteff-Delivery-Id: a retry can arrive more than once.
  console.log(event.event, event.data);
  res.sendStatus(200);
});

app.listen(3000);

Test vector#

Check your implementation with these fixed values. If your code produces the same signature, it verifies exactly like heysteff signs:

FieldValue
Secretwhsec_test_123
X-Heysteff-Timestamp1730000000
Body{"event":"message:received","data":{"id":"msg_1"}}
X-Heysteff-Signaturev1=7fc6ea897401575af79e1b73b304bd864c05f1fef25dca2a0e8ffbf9b0d18ab4

With the test vector, turn off the 300 second check: that timestamp is from 2024.

Retries#

Respond with a 2xx status within 10 seconds and process the event afterwards. If a delivery fails (network error, timeout or a non 2xx status), heysteff retries it with growing waits: 1, 2, 4, 8 and 16 minutes. After the sixth failed attempt the delivery is marked as failed.

Batch quick messages#

Customers often send several messages in a row. With batch on POST or PATCH /v1/webhooks (or in the console), message:received events from the same conversation that arrive within wait_seconds go out in a single POST, or earlier if max_size pile up:

JSON
{ "batch": { "wait_seconds": 5, "max_size": 50 } }

wait_seconds goes from 1 to 60 (default 5) and max_size from 1 to 100 (default 50). "batch": null turns it off. The batched POST carries X-Heysteff-Batch: true, X-Heysteff-Batch-Size and this body, with the events in order:

JSON
{
  "id": "b3c1…", "tenant_id": "2c7e…", "event": "message:received",
  "ts": "2026-11-14T14:03:57.000Z", "batch": true,
  "events": [ { "id": "…", "event": "message:received", "data": { … } }, … ]
}

Automatic pause#

If a webhook had 40 or more deliveries in 15 minutes and 85% or more of them failed, it is paused: is_active becomes false, and paused_at and paused_reason (failure_rate) say when and why. We email the owners and admins, and the other webhooks in the workspace receive webhook.paused.

Delivery log and retries#

Every delivery is logged: which event it was, how many attempts it took, the HTTP status and duration of the last one, why it failed and an excerpt of what your server answered. You see it in Settings → Developers → Outbound webhooks, through the API with a key that has the webhooks:read scope, and from your AI through the MCP (list_webhook_deliveries, retry_webhook_delivery). The signing secret never shows up.

EndpointScopeWhat for
GET /v1/webhookswebhooks:readYour webhooks, with the last 2xx, the last failure and consecutive failures.
GET /v1/webhooks/{id}/deliverieswebhooks:readA webhook's deliveries, newest first, cursor-paginated. Filters: status, event, since, until.
POST /v1/webhook-deliveries/{id}/retrywebhooks:writeSends a failed delivery again right now.

The state field of each delivery tells you where it stands:

stateMeaning
pendingNot attempted yet.
retryingThe last attempt failed and it will be retried automatically (next_retry_at).
succeededYour server answered 2xx.
failedIt used up its 6 automatic attempts.
skippedThe event does not apply to that webhook (a webhook with format: "meta" and a deleted message or one from another channel). Nothing was sent, it does not count as a failure and error is not_applicable.

The status=failed filter returns both failing states (retrying and failed); status=succeeded and status=pending return the others.

curl
curl "https://app.heysteff.com/api/v1/webhooks/$WEBHOOK_ID/deliveries?status=failed&limit=20" \
  -H "Authorization: Bearer $HEYSTEFF_API_KEY"
JSON
{
  "data": [
    {
      "id": "8b0f6a1e-2c3d-4e5f-9a6b-7c8d9e0f1a2b",
      "webhook_id": "3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d",
      "event": "message.received",
      "state": "retrying",
      "attempts": 2,
      "max_attempts": 6,
      "http_status": 401,
      "duration_ms": 184,
      "error": "http_status",
      "response_body_excerpt": "invalid signature",
      "retryable": true,
      "created_at": "2026-11-04T14:03:52.104Z",
      "last_attempt_at": "2026-11-04T14:05:01.880Z",
      "next_retry_at": "2026-11-04T14:09:01.880Z"
    }
  ],
  "cursor": { "next": null, "has_more": false }
}

Debug a failed delivery#

  1. Find the delivery with status=failed. Its id is the X-Heysteff-Delivery-Id your server received: use it to find it in your logs.
  2. If error is http_status, your server answered something other than 2xx: look at http_status and response_body_excerpt. A 401 or 403 is almost always the signature: check that you compute the HMAC over the raw body and with THAT webhook's secret.
  3. If error is timeout, your server took longer than 10 seconds: answer 2xx first and process later. duration_ms tells you how long it took.
  4. If it is connection_refused, dns_failed, tls_error or connection_reset, heysteff could not connect to your URL: check that it is public, uses HTTPS and has a valid certificate. destination_blocked means the URL points to a private network, which heysteff never calls.
  5. Once it is fixed, retry. The same body goes out with the same X-Heysteff-Delivery-Id and a fresh signature. A retry does not reset the counter: if a delivery that had used up its attempts fails again, it goes back to failed.
curl
curl -X POST "https://app.heysteff.com/api/v1/webhook-deliveries/$DELIVERY_ID/retry" \
  -H "Authorization: Bearer $HEYSTEFF_API_KEY"

With the SDK (version 0.2.0 or later):

TypeScript
import { Heysteff } from '@heysteff/sdk';

const heysteff = new Heysteff();
const [webhook] = await heysteff.webhooks.list();

// Walk every failed delivery of the last 24 hours.
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
for await (const d of heysteff.webhooks.listAllDeliveries(webhook.id, { status: 'failed', since })) {
  console.log(d.id, d.event, d.error, d.http_status, d.response_body_excerpt);
  if (d.retryable) await heysteff.webhooks.retryDelivery(d.id);
}

You can retry up to 120 deliveries per hour per workspace. Repeating the request while the delivery waits its turn does not duplicate it.

Test locally without a tunnel#

To test your receiver on your machine with real events you need neither a tunnel nor a public URL. heysteff listen opens an outbound connection to heysteff and forwards each event of your workspace to your local server, with the same envelope and the same signed headers as a real delivery:

Terminal
heysteff login
heysteff listen --forward-to http://localhost:3000/webhooks/heysteff

Locally, listen signs with its own secret, printed on startup (whsec_local_…), not with your webhooks' secret. Put that value in the HEYSTEFF_WEBHOOK_SECRET of your local environment, or pass your secret with --secret so the CLI signs with it:

Terminal
heysteff listen --forward-to http://localhost:3000/webhooks/heysteff --secret "$HEYSTEFF_WEBHOOK_SECRET"

With the SDK (0.7.0 or later), the receiver is one line: it checks the signature and the age and answers 200, 400 when the signature is invalid or 500 when your onEvent fails, and then heysteff retries. There are Hono and Express examples too.

TypeScript
// app/webhooks/heysteff/route.ts (Next.js App Router)
import { createWebhookHandler } from '@heysteff/sdk';

export const POST = createWebhookHandler({
  secret: process.env.HEYSTEFF_WEBHOOK_SECRET!,
  onEvent: async (event) => {
    if (event.event === 'message:received') {
      console.log(event.data.external_user_id, event.data.content);
    }
  },
});

Next to each event, listen shows the status your server answered and how long it took (→ 200 OK (12 ms)). To send a sample event without waiting for a real one, use heysteff webhooks trigger.

Alternative: read the event stream#

If you cannot expose a public URL, you have two paths with the events:read scope: listen to GET /v1/events/stream, a live Server-Sent Events stream that resumes with Last-Event-ID (format in CLI; in the SDK, heysteff.events.stream()), or poll GET /v1/events?after_seq=<last>. Each response of the latter includes the next_after_seq for the next call.