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#
Build an endpoint
Any HTTPS URL that accepts a
POSTwith a JSON body and answers2xx. Answer quickly: the request times out after three seconds during a call.Choose a signing secret
Any long random string. Keep a copy: you need it to verify requests, and AnswerStack never shows it again.
bashopenssl rand -hex 32Add the connection
In the admin app, go to Connections → Add a connection → Connect Generic webhook and fill in:
Field What to put Name How your team will recognise it. Signing secret The string you just generated. Webhook URL Your endpoint. Press Check and save. AnswerStack sends a
pingevent immediately; if your endpoint does not answer2xx, nothing is saved.The URL must start with
https://. A plainhttp://address is refused when you save. A webhook saved overhttp://before this rule keeps working, but its card on Connections shows a warning until you reconnect it with anhttps://address.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#
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":{ … }}| Part | Always |
|---|---|
| Method | POST |
content-type | application/json |
idempotency-key | The same value as idempotencyKey in the body |
x-answerstack-signature | Lowercase hex HMAC-SHA256 of the raw body, signed with your secret |
| Body | An 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.
{ "event": "ping", "idempotencyKey": "ping-1758463200000", "data": {} }contact.upserted#
Sent during the call, as soon as the caller's details are confirmed.
{
"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": {}
}
}| Field | Meaning |
|---|---|
contactId | Your own ID if you returned one, otherwise call:<call id> |
locationId | The community's CRM location, when it has one |
caller | The person on the phone. firstName is always there; the rest depends on what was confirmed |
resident | The person who would move in, when that is somebody else |
fields | Every answer collected, by its short name |
fieldMap | How your playbook maps those answers to CRM fields, if you set any |
source | Which call, which playbook version, and which number was dialled |
call.logged#
Sent at hangup, once the call record is complete.
{
"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.
{
"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.
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);# Check a payload you captured, to confirm your secret is right.
BODY='{"event":"ping","idempotencyKey":"ping-1758463200000","data":{}}'
SECRET='your-signing-secret'
printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //'
# Compare with the x-answerstack-signature header on the request.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:
| Event | Key |
|---|---|
contact.upserted | contact-<call id> |
call.logged | call-<call id> |
task.created | The callback's own reference |
ping | A 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#
| Event | Retries |
|---|---|
call.logged and task.created | Up 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.upserted | Delivered once, during the call. A failure is recorded on the call but not retried — the same details arrive again in call.logged at hangup. |
ping | Not retried. |
Timeouts#
| When | Timeout |
|---|---|
| During a live call | 3 seconds |
| From the retry queue | 10 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 answer | AnswerStack does |
|---|---|
2xx | Marks it delivered. |
408, 429, 5xx | Retries, honouring Retry-After. |
400, 401, 403, 404, 409, 422 | Stops. 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 acontact.upsertedand acall.loggedfrom your first test call. - Replaying the same payload twice creates exactly one record on your side.