Integrations

Generic webhook

Send new contacts, call logs and follow-up tasks to any URL, with a signed payload you can verify.

The Generic webhook connector posts what the assistant collected to a URL you own. Use it when your CRM is not WelcomeHome, or to feed a data warehouse, an automation tool or an internal service.

Warning

A webhook is one-way. It cannot look a caller up, read their history, or book a tour, because there is nothing to read back from. With a webhook as your only CRM, the assistant greets everybody as a new caller and captures callbacks instead of booking. For booking you need a connector that supports a calendar, such as WelcomeHome.

Set it up#

  1. Build an endpoint

    Any HTTPS URL that accepts a POST with a JSON body and answers 2xx. Answer quickly: the request times out after three seconds during a call.

  2. Choose a signing secret

    Any long random string. Keep a copy: you need it to verify requests, and AnswerStack never shows it again.

    bash
    openssl rand -hex 32
  3. Add the connection

    In the admin app, go to Connections → Add a connection → Connect Generic webhook and fill in:

    FieldWhat to put
    NameHow your team will recognise it.
    Signing secretThe string you just generated.
    Webhook URLYour endpoint.

    Press Check and save. AnswerStack sends a ping event immediately; if your endpoint does not answer 2xx, nothing is saved.

    The URL must start with https://. A plain http:// address is refused when you save. A webhook saved over http:// before this rule keeps working, but its card on Connections shows a warning until you reconnect it with an https:// address.

  4. Point a playbook at it

    Open the playbook's CRM & calendar tab, choose the webhook as the CRM, and publish.

Every request looks the same#

http
POST /your/endpoint HTTP/1.1
Content-Type: application/json
Idempotency-Key: call-call_2ZPh7XbT0v9Ao4iJ3qK1mN8sRgE
X-AnswerStack-Signature: 4f1c3d2b9a8e7f60512a4b3c6d7e8f901a2b3c4d5e6f708192a3b4c5d6e7f809

{"event":"call.logged","idempotencyKey":"call-call_2ZPh7Xb…","data":{ … }}
PartAlways
MethodPOST
content-typeapplication/json
idempotency-keyThe same value as idempotencyKey in the body
x-answerstack-signatureLowercase hex HMAC-SHA256 of the raw body, signed with your secret
BodyAn object with exactly three keys: event, idempotencyKey, data

The events#

ping#

Sent when the connection is saved and on a schedule afterwards, to check your endpoint is alive.

json
{ "event": "ping", "idempotencyKey": "ping-1758463200000", "data": {} }

contact.upserted#

Sent during the call, as soon as the caller's details are confirmed.

json
{
  "event": "contact.upserted",
  "idempotencyKey": "contact-call_2ZPh7XbT0v9Ao4iJ3qK1mN8sRgE",
  "data": {
    "contactId": "call:call_2ZPh7XbT0v9Ao4iJ3qK1mN8sRgE",
    "locationId": "12",
    "caller": {
      "firstName": "Maya",
      "lastName": "Lopez",
      "phone": "+15555550177",
      "email": "[email protected]",
      "relationship": "Daughter"
    },
    "resident": { "firstName": "Rosa" },
    "fields": { "care_type": "assisted_living", "timeline": "1_3_months" },
    "fieldMap": { "care_type": "care_type_id" },
    "source": {
      "callId": "call_2ZPh7XbT0v9Ao4iJ3qK1mN8sRgE",
      "playbookId": "pb_2ZPh1Qw8sLm3VbR7yT0kD5nXcJa",
      "playbookVersion": 3,
      "dialedNumber": "+15555550100"
    },
    "options": {}
  }
}
FieldMeaning
contactIdYour own ID if you returned one, otherwise call:<call id>
locationIdThe community's CRM location, when it has one
callerThe person on the phone. firstName is always there; the rest depends on what was confirmed
residentThe person who would move in, when that is somebody else
fieldsEvery answer collected, by its short name
fieldMapHow your playbook maps those answers to CRM fields, if you set any
sourceWhich call, which playbook version, and which number was dialled

call.logged#

Sent at hangup, once the call record is complete.

json
{
  "event": "call.logged",
  "idempotencyKey": "call-call_2ZPh7XbT0v9Ao4iJ3qK1mN8sRgE",
  "data": {
    "contactId": "call:call_2ZPh7XbT0v9Ao4iJ3qK1mN8sRgE",
    "callId": "call_2ZPh7XbT0v9Ao4iJ3qK1mN8sRgE",
    "direction": "inbound",
    "startedAt": "2026-09-21T14:00:00.000Z",
    "endedAt": "2026-09-21T14:05:00.000Z",
    "durationSeconds": 300,
    "outcome": "callback",
    "summary": "Maya called about assisted living for her mother Rosa.\nBudget around $4,200 a month.\nCallback requested for tomorrow morning.",
    "collectedFields": { "care_type": "assisted_living", "timeline": "1_3_months" },
    "transcriptUrl": "https://app.example.com/calls/call_2ZPh7Xb…",
    "playbookVersion": 3,
    "options": {}
  }
}

outcome is one of booked, callback, transferred, not_fit, declined, abandoned, booking_failed, redirected, error. recordingUrl is included only when a recording exists.

task.created#

Sent when the assistant captures a callback that somebody needs to return.

json
{
  "event": "task.created",
  "idempotencyKey": "cb-cbk_2ZPh8Hn4Ke2WqZ6uP1xG9rFtBvC",
  "data": {
    "contactId": "call:call_2ZPh7XbT0v9Ao4iJ3qK1mN8sRgE",
    "locationId": "12",
    "title": "Call back Maya Lopez",
    "notes": "Call: https://app.example.com/calls/call_2ZPh7Xb…",
    "dueAt": "2026-09-21T18:00:00.000Z",
    "idempotencyKey": "cb-cbk_2ZPh8Hn4Ke2WqZ6uP1xG9rFtBvC"
  }
}

Verifying the signature#

Sign the raw request body, byte for byte, as it arrived. Do not parse the JSON and re-serialise it: key order and whitespace matter.

The value is the bare lowercase hex digest, with no sha256= prefix and no timestamp.

js
import crypto from 'node:crypto';
import express from 'express';

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

// Keep the raw body: the signature covers exactly these bytes.
app.use(express.raw({ type: 'application/json' }));

app.post('/answerstack', (req, res) => {
const signature = req.get('x-answerstack-signature') ?? '';
const expected = crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');

const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(signature, 'utf8');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).json({ error: 'bad signature' });
}

const { event, idempotencyKey, data } = JSON.parse(req.body.toString('utf8'));

// Idempotency: you may see the same key more than once.
if (alreadyHandled(idempotencyKey)) return res.status(200).end();

switch (event) {
case 'ping':
break;
case 'contact.upserted':
upsertContact(data);
break;
case 'call.logged':
recordCall(data);
break;
case 'task.created':
createTask(data);
break;
}

remember(idempotencyKey);
res.status(200).end();
});

app.listen(3000);

This is how AnswerStack signs the requests it sends you. Requests your system sends AnswerStack are signed differently, with a secret of their own and a timestamp: see Incoming events.

Warning

Always compare signatures in constant time, and reject the request when it does not match. Without that check, anybody who finds your URL can post whatever they like to it.

Idempotency and retries#

Every event carries a key that is stable for the thing it describes:

EventKey
contact.upsertedcontact-<call id>
call.loggedcall-<call id>
task.createdThe callback's own reference
pingA timestamp, not meant to be deduplicated

Store the key and ignore a repeat. AnswerStack may send the same key more than once, and treats any 2xx as delivered.

What is retried, and what is not#

EventRetries
call.logged and task.createdUp to 8 attempts through a queue: after about 30 seconds, then 1, 2, 4, 8, 16 and 32 minutes, capped at an hour between tries. A Retry-After header is honoured when it asks for longer. After that the delivery is dead-lettered and recorded in your account's audit log.
contact.upsertedDelivered once, during the call. A failure is recorded on the call but not retried — the same details arrive again in call.logged at hangup.
pingNot retried.

Timeouts#

WhenTimeout
During a live call3 seconds
From the retry queue10 seconds

A slow endpoint is worse than a failing one: it makes the caller wait. Answer 2xx first and do your work afterwards.

Status codes#

You answerAnswerStack does
2xxMarks it delivered.
408, 429, 5xxRetries, honouring Retry-After.
400, 401, 403, 404, 409, 422Stops. This is a configuration problem, and retrying will not help.

What good looks like#

  • The connection card says Connected, and Test connection reports Working.
  • Your endpoint logs a ping, then a contact.upserted and a call.logged from your first test call.
  • Replaying the same payload twice creates exactly one record on your side.