Skip to main content

Webhooks

A webhook tells a system you own, at an https URL you choose, when something happens in your organization: a response arrives, or a respondent saves or submits a survey. Press'nXPress sends one signed POST request for each event. Failed requests are retried, and every delivery is listed in the dashboard.

Use webhooks to put feedback into your CRM, your ticketing system or your own database as it arrives, without having to ask for it.

This page has two parts:

Who can set up webhooks​

Webhooks live on the Organization Settings page, in Manage > Settings > Organization. See Organization Settings.

The Webhooks card only appears if your account has the ManageWebhooks permission for this organization. If you cannot see the card, ask your Press'nXPress administrator. The people with this permission are also the ones who get an email when a webhook is suspended.

Set up a webhook​

The Webhooks card on Organization Settings

Each row on the card is one webhook. It shows the name, a status chip (Active or Suspended), the URL, and the events it receives with its settle delay. The buttons on the right:

  • Enabled / Disabled: turns the webhook on or off without deleting it.
  • Edit: changes everything except the secret.
  • Deliveries: the log of every request sent to this webhook. See Deliveries.
  • Rotate secret: replaces the signing secret. See Rotate the secret.
  • Delete: removes the webhook.

Add a webhook​

  1. Click Add webhook.
  2. Type a Name, such as "CRM sync". Only you see it.
  3. Type the URL that will receive the requests. It must start with https:// and point to a public host (see Which URLs are accepted).
  4. Under Events, pick at least one:
    • Responses: each rating or comment left on a touchpoint.
    • Survey Answer saved (partial): each time a respondent's progress on a survey is saved, before they finish.
    • Survey Answer committed: when a respondent finishes a survey, and again whenever a finished answer is saved.
  5. Optionally, add an Extra header: a name and a value sent with every request. Use it only for a receiver that cannot verify the signature, such as a tool that only checks a fixed token.
  6. Set the Settle delay in seconds, from 0 to 3600. We wait this long before sending, so a comment, its sentiment or a label that arrives shortly after the rating is included. See Timing and the settle delay.
  7. Optionally, narrow the webhook with Filters (see below).
  8. Click Create.

The New webhook window

The secret appears once, straight after you create the webhook. Copy it and give it to whoever runs the receiving system. It is never shown again; if you lose it, rotate it to get a new one.

Filters​

A webhook covers your organization and all its branches. Filters narrow it. Leave a filter empty to accept everything.

  • Touchpoints: only responses and Survey Answers from these touchpoints.
  • Surveys: only Survey Answers to these surveys. A response never matches a survey filter.
  • Moods: only responses with these ratings (happy, meh, sad). A categorical response has no mood and never matches. A Survey Answer never matches a mood filter.
  • Agents: only responses attributed to these agents. A terminal press has no agent and never matches. A Survey Answer never matches an agent filter.

Within one filter, any of the chosen values matches. Across filters, all of them must match. So a webhook with a Moods filter of sad and an Agents filter of Dana only receives sad responses attributed to Dana.

caution

Because a Survey Answer never matches a mood or agent filter, a webhook that has either of these filters receives no Survey Answer events at all.

Send a test​

Open Deliveries and click Send test. We send a ping event to the URL so you can check that the receiver is reachable and that it verifies the signature. A test is listed in the deliveries like any other request.

Rotate the secret​

Click Rotate secret, then confirm. The new secret is shown once, in the same way as after you create a webhook.

The new secret, shown once after rotating

The old secret stops verifying at once, so update the receiving system straight away. Rotate the secret whenever you think it may have leaked.

Deliveries​

Click Deliveries on a webhook to see every request we made to it in the last 30 days, newest first.

The Deliveries list

ColumnWhat it shows
EventThe event type, such as response.received, and the start of the event id.
SubjectWhat the event is about, such as Response #11 or Survey Answer #19.
StatusPending (waiting or retrying), Succeeded, Failed (gave up), or Superseded (a newer state of the same Survey Answer replaced it, so it was never sent).
AttemptsHow many times we tried.
HTTPThe status code your server last answered with. A dash means there was no answer, for example a timeout.
WhenWhen the delivery was made.
DetailWhy the delivery failed, if it did.

Redeliver on a row sends the same event again, with the same event id. Use it after you fix a problem on the receiving side. A superseded delivery cannot be redelivered.

Refresh reloads the list; Send test sends a ping. Deliveries are kept for 30 days, together with the first kilobyte of your server's answer to each attempt.

When a webhook is suspended​

If 20 events in a row use up all their attempts, we stop sending to the webhook and mark it Suspended. A webhook is also suspended at once if your server answers 410 Gone.

A suspended webhook

Everyone in the organization who can manage webhooks receives an email, titled Webhook "CRM sync" has been suspended:

Your webhook CRM sync (https://…) has been suspended because 20 consecutive events could not be delivered after all retries.

No further events will be sent to it until you re-enable it under Organization Settings → Webhooks. Re-enabling clears the failure count.

To start it again, fix the receiving system, then switch the webhook back to Enabled. Re-enabling clears the failure count. Only an enabled, active webhook receives events, so nothing is queued for it while it is suspended or disabled. Use Redeliver in Deliveries for any failed events you still need.

Which URLs are accepted​

  • The URL must use https.
  • It must point to a public host. Private and local addresses are refused: localhost, names ending in .local or .internal, and private IPv4 or IPv6 addresses.
  • A host whose name resolves to a private address is refused when we send, and the delivery fails with a PrivateAddress detail.
  • We never follow redirects. Give the final URL.

For developers: receiving webhooks​

The request​

One POST per event, Content-Type: application/json, with these headers:

HeaderMeaning
X-PXP-Event-IdId of the event. Deduplicate on it: you may receive the same event twice.
X-PXP-Event-Typeresponse.received, survey.answer.saved, survey.answer.committed, or ping. Same as type in the body.
X-PXP-Webhook-IdThe webhook this was sent to.
X-PXP-TimestampUnix seconds when the request was signed.
X-PXP-Signaturev1=<hex>. See Verify the signature.
User-AgentPressnXPress-Webhooks/1.

If you set an Extra header on the webhook, it is sent as well, exactly as you typed it.

Answer within 10 seconds. We do not follow redirects.

Verify the signature​

Every webhook has a secret that starts with whsec_. To verify a request:

  1. Take the raw request body exactly as received, before parsing it.
  2. Build the string <X-PXP-Timestamp>.<raw body>: the timestamp, a dot, then the body.
  3. Compute the HMAC-SHA256 of that string, using the whole secret (including whsec_) as the key. Hex-encode it.
  4. Compare it, in constant time, with the value after v1= in X-PXP-Signature.
  5. Reject requests whose timestamp is more than five minutes away from your clock. This stops a captured request from being replayed.
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(rawBody, headers, secret) {
const timestamp = headers["x-pxp-timestamp"];
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const received = String(headers["x-pxp-signature"]).replace(/^v1=/, "");
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
return fresh && received.length === expected.length && timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}

After you rotate the secret, the old one stops verifying at once.

Answering, retries and suspension​

Your answerWhat we do
Any 2xxDelivered. The body is ignored; we keep its first kilobyte for your debugging.
5xx, 408, 429, a timeout or a connection errorRetry with exponential backoff, starting after a minute: 6 attempts over about an hour.
Any other 4xxNot retried, because the same request would get the same answer.
410 GoneThe webhook is suspended immediately.

When 20 events in a row use up their attempts, the webhook is suspended and the people who can manage webhooks are emailed (see When a webhook is suspended). Response and Survey Answer events count towards the same 20.

Event ids and deduplication​

Delivery is at least once. A retry, a Redeliver from the dashboard, or a re-run on our side sends the same X-PXP-Event-Id (the body's id). Make your handler idempotent on it: if you have already processed an id, answer 2xx and do nothing.

An event id belongs to one revision of what it describes, not to the response or survey answer as a whole:

  • Responses. A response can change after its first event was sent. Most often the customer types their comment a minute or two after tapping the smiley, but a why, an agent or a corrected mood can also arrive later. When that happens we send another response.received event, with a new id and a later data.response.updatedAt. Deduplicating on the event id keeps both events, which is what you want: the second one is an update, not a duplicate.
  • Survey Answers. Each saved state of an answer has its own id, fixed by data.surveyAnswer.revision and the event type.
Do not deduplicate on data.response.id

data.response.id is the response's own id and is the same in every event about that response. If you drop events whose data.response.id you have already seen, you throw away exactly the updates described above.

Labels do not send a new event. Labels are stored apart from the response, so a label added after the first event does not produce another event. A label that is already on the response when the event is sent is included in labels. A longer settle delay gives labels more time to arrive.

Events have no ordering guarantee. To keep the latest state:

  • of a response, compare data.response.updatedAt. Do not use occurredAt: it is when the feedback happened, so it is the same in every event about that response.
  • of a survey answer, compare data.surveyAnswer.revision. When two events have the same revision, committed is later than saved.

An event older than one you already hold is never news; ignore it.

Timing and the settle delay​

Response events are sent once our analytics pipeline has recorded the response, then after the webhook's settle delay. A comment, its sentiment and labels can arrive a little after the rating; the settle delay holds the event so they are included in the first event. If something other than a label arrives later, you get a second event (see above).

A Survey Answer event is sent once its settle delay has passed, counted from when we received the save. If the same survey answer is saved again during that time, only the latest state is sent. A settle delay of a minute therefore turns a respondent's burst of saves into one request.

The body​

Every event shares one envelope and adds a data object that depends on type. apiVersion names the schema of data. It is "2" for every event type today, and it changes only when a receiver could notice. New fields can be added without a version change, so ignore fields you do not know.

{
"id": "5b3f4e0a-2c6e-5d1a-9f0b-8c7d6e5f4a3b",
"type": "response.received",
"apiVersion": "2",
"occurredAt": "2026-09-10T10:20:00.000Z",
"webhookId": 7,
"organizationId": 1,
"data": { "...": "see below" }
}

ping​

Sent when you click Send test. data is { "message": "Test event from Press'nXPress" }.

response.received​

Everything we know about one response at the moment the event is sent. Fields that a response from some collector never has are null, not missing.

{
"id": "5b3f4e0a-2c6e-5d1a-9f0b-8c7d6e5f4a3b",
"type": "response.received",
"apiVersion": "2",
"occurredAt": "2026-09-10T10:20:00.000Z",
"webhookId": 7,
"organizationId": 1,
"data": {
"response": {
"id": 501,
"collector": "Terminal",
"receivedAt": "2026-09-10T10:20:00.000Z",
"updatedAt": "2026-09-10T10:22:05.000Z",
"localTime": "2026-09-10T13:20:00",
"timezone": "Asia/Amman",
"mood": -1,
"terminalId": 42,
"context": { "table": 4 }
},
"touchpoint": {
"id": 1,
"name": "Front desk",
"language": "en",
"timezone": "Asia/Amman",
"collector": "Terminal"
},
"organization": { "id": 1, "name": "Prince Hamza Hospital" },
"branch": { "id": 10, "name": "Emergency" },
"question": { "id": 3, "title": "How was your visit?" },
"why": { "id": 12, "name": "Wait time" },
"comment": {
"id": 9,
"text": "Waited forty minutes",
"kind": "comment",
"sentiment": { "score": -0.6, "magnitude": 0.9 },
"voiceUrl": null,
"receivedAt": "2026-09-10T10:22:00.000Z"
},
"choices": [],
"labels": [{ "id": 5, "name": "Long wait", "category": "Operations" }],
"agent": { "id": 4, "firstName": "Dana", "lastName": "Haddad", "email": "dana@example.com" },
"respondent": {
"id": 7,
"identifier": "+962790000000",
"firstName": null,
"lastName": null,
"email": "r@example.com",
"phone": "+962790000000",
"company": null,
"extra": null
}
}
}

Field notes:

  • response.mood is 1 happy, 0 meh, -1 sad, or null for a categorical response.
  • response.updatedAt is when the response last changed. Use it to order two events about the same response.
  • response.collector and touchpoint.collector name the channel: Terminal, Website (the QR collector), Widget, Email, SMS, IVR, Tablet, Signature.
  • response.localTime is the wall-clock time at the touchpoint, with no offset; response.timezone says where. receivedAt and occurredAt are the same moment in UTC.
  • question.title and why.name are plain text in the touchpoint's language (touchpoint.language), falling back to English.
  • comment.kind is comment (free text), why (a chosen why), or abc (a terminal ABC press).
  • choices is filled for categorical responses. labels holds every label on the response or its comment when the event is sent.

survey.answer.saved and survey.answer.committed​

A Survey Answer is one respondent's answers to a multi-question survey. We send it:

  • as survey.answer.saved each time the respondent's progress is saved partway through, with data.surveyAnswer.status set to "partial";
  • as survey.answer.committed when they submit it, with status set to "committed".

Each event type is chosen on the webhook separately. A webhook that only wants finished surveys picks Survey Answer committed alone. These events are signed, retried and suspended in the same way as response.received, with the same secret.

Every event is the whole answer, not a change. answers holds everything answered so far. A later event about the same surveyAnswer.id replaces an earlier one; it does not add to it.

Superseded saves. We never send a state of an answer that is older than one we already hold for your webhook. If a newer save arrives while an earlier one is still waiting out the settle delay, the earlier one is superseded: it is never sent, and Deliveries lists it as Superseded. So you can receive a committed event with no saved event before it. That is normal.

Redelivering a Survey Answer sends the same bytes as the original request: the body is frozen when the event is recorded, so the signature verifies over the same string. Only X-PXP-Timestamp and X-PXP-Signature are new.

What is in data:

  • surveyAnswer: id, status ("partial" or "committed"), revision and language. The language is the one the respondent answered in; question text, choice text and why names are in that language, falling back to English.
  • survey: id, and version, the version of the survey the respondent was shown.
  • touchpoint: id, token and name.
  • organization and branch: id and name. These are read when we process the event, so a touchpoint moved to another branch reports its new branch.
  • respondent:
    • id: null for an anonymous respondent.
    • info: the answers to respondent information fields (name, email and so on), each as { fieldId, question, value }.
    • context: what the collecting channel attached, or null. Only an IVR call attaches anything today: { "_tag": "IVR", "to", "from", "contact", "agent" } (contact can be null). Ignore a _tag you do not know; new channels may be added.
  • answers: one entry per answered question, in survey order, told apart by type. fieldId identifies the question within the survey version, and question is its text as the respondent saw it. questionId is the question's id in the question bank (an open comment's id for openComment and voiceText); a why answer only has one inside an Independent source. A mood is -1 sad, 0 meh, 1 happy.
    • scale: scale (the number of points: ThreePoints, FivePoints, SevenPoints, TenPoints or ElevenPoints), value (the point chosen), label (that point's text, or null if the point is no longer on this version's scale) and mood. It carries no why or comment; those are answers of their own.

    • why: whys ([{ id, name }], the first is the main one), mood, and source, which is one of:

      • { "_tag": "FollowUp", "followUpOf": <fieldId> }: a follow-up Why question about the scale whose fieldId is followUpOf. Its mood is the mood of the point chosen on that scale.
      • { "_tag": "Independent", "questionId": <number> }: a Why question of its own, backed by the bank question questionId. Its mood comes from that question.

      mood is null when there is none to give, for example a follow-up whose scale was not answered.

    • multipleChoice: choices ([{ id, text }]). This is a Categorical question.

    • openComment: text, and source, which is one of:

      • { "_tag": "FollowUp", "followUpOf": <fieldId> }: a follow-up comment about that scale. It has no mood of its own; use the mood of the scale answer it follows.
      • { "_tag": "Standalone" }: a comment question of its own.
    • voiceText: text (typed) and transcript (spoken). Either can be null. No audio URL is sent.

A follow-up is only asked when its Show when rule matches, so it is missing from answers when the respondent was not shown it. A why that has been deleted keeps its id with an empty name, and a choice that is no longer offered keeps its id with empty text. No IP address or user agent is sent.

{
"id": "0d6a1f7e-3b2c-5e4d-8a9b-1c2d3e4f5a6b",
"type": "survey.answer.committed",
"apiVersion": "2",
"occurredAt": "2026-09-23T09:15:30.000Z",
"webhookId": 7,
"organizationId": 1,
"data": {
"surveyAnswer": {
"id": 8812,
"status": "committed",
"revision": "2026-09-23T09:15:30.000Z",
"language": "en"
},
"survey": {
"id": "3f8e2d1c-4b5a-4968-8776-5a4b3c2d1e0f",
"version": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"
},
"touchpoint": {
"id": 12,
"token": "c4b3a291-8f7e-4d6c-9b5a-4e3d2c1b0a9f",
"name": "Discharge survey"
},
"organization": { "id": 1, "name": "Prince Hamza Hospital" },
"branch": { "id": 10, "name": "Emergency" },
"respondent": {
"id": 305,
"info": [
{ "fieldId": "6f1c2a4e-0b7d-4c3e-9a5f-000000000001", "question": "Your email", "value": "r@example.com" }
],
"context": {
"_tag": "IVR",
"to": "+15550100",
"from": "+15550123",
"contact": "Ada Lovelace",
"agent": "Nurse Station 2"
}
},
"answers": [
{
"type": "scale",
"fieldId": "6f1c2a4e-0b7d-4c3e-9a5f-000000000002",
"question": "How likely are you to recommend us?",
"questionId": 41,
"scale": "FivePoints",
"value": 3,
"label": "Not sure",
"mood": -1
},
{
"type": "why",
"fieldId": "6f1c2a4e-0b7d-4c3e-9a5f-000000000003",
"question": "What should we improve?",
"whys": [
{ "id": 12, "name": "Wait time" },
{ "id": 15, "name": "Staff attitude" }
],
"mood": -1,
"source": { "_tag": "FollowUp", "followUpOf": "6f1c2a4e-0b7d-4c3e-9a5f-000000000002" }
},
{
"type": "openComment",
"fieldId": "6f1c2a4e-0b7d-4c3e-9a5f-000000000004",
"question": "Tell us more",
"questionId": 16,
"text": "Nobody explained the medication",
"source": { "_tag": "FollowUp", "followUpOf": "6f1c2a4e-0b7d-4c3e-9a5f-000000000002" }
},
{
"type": "multipleChoice",
"fieldId": "6f1c2a4e-0b7d-4c3e-9a5f-000000000005",
"question": "Which services did you use?",
"questionId": 43,
"choices": [{ "id": 3, "text": "Pharmacy" }]
},
{
"type": "openComment",
"fieldId": "6f1c2a4e-0b7d-4c3e-9a5f-000000000006",
"question": "Anything else?",
"questionId": 17,
"text": "The night nurse was kind",
"source": { "_tag": "Standalone" }
},
{
"type": "voiceText",
"fieldId": "6f1c2a4e-0b7d-4c3e-9a5f-000000000007",
"question": "Tell us in your own words",
"questionId": 18,
"text": null,
"transcript": "The waiting room was cold"
},
{
"type": "why",
"fieldId": "6f1c2a4e-0b7d-4c3e-9a5f-000000000008",
"question": "What did you like?",
"whys": [{ "id": 20, "name": "Cleanliness" }],
"mood": 1,
"source": { "_tag": "Independent", "questionId": 44 }
}
]
}
}

A partial save looks the same, with "type": "survey.answer.saved" and "status": "partial"; answers then holds only the questions answered so far.

A save can clear an answer. If a respondent's change leaves nothing answered (for example, they unselect the choice that showed the only question they had answered), we send a survey.answer.saved event with an empty answers list and an empty respondent.info. Because every event is the whole answer, it replaces what you hold. A later save or commit of the same surveyAnswer.id can follow.

Voice transcripts

A voice answer is transcribed after the respondent saves it, and the transcript arriving later does not send a new Survey Answer event. So transcript is usually null, unless the respondent saved again after the transcript was ready.