Webhooks

Last updated 2 October 2026

A Xenpai project can send its submissions to another app: a CRM, a ticketing or accounting system, your own software, or Zapier, Make and n8n. The owner gives Xenpai an address (a webhook URL), and for each event Xenpai sends a POST request there with a JSON message about the submission. This page is for whoever sets up the receiving side.

Events

The project's owner chooses which of these are sent (all four unless they chose fewer).

eventWhen
submission.createdA new submission (or one put back after being caught as spam).
submission.updatedIts answers were changed, by the team or by the person through their update link.
submission.status_changedIt was moved to another status. previous_status says where it was.
payment.receivedIts card payment went through.

Submissions caught as spam are never sent.

The message

Every message describes the submission as it is when it's sent:

{
  "id": "01a0fb75-beb8-75a8-a4a9-6b85f1e572d3",
  "event": "submission.created",
  "sent_at": "2026-10-02T15:06:12Z",
  "project": { "id": "01a0fb5f-…", "name": "Harmony Summit 2026" },
  "form": { "key": "registration", "title": "Harmony Summit 2026 registration" },
  "submission": {
    "id": "01a0fadf-a9d2-75fc-912b-56aba955df58",
    "ref": "56ABA955DF58",
    "submitted_at": "2026-10-02T15:06:10Z",
    "submitted_by": "Emily Carter",
    "status": { "key": "confirmed", "label": "Confirmed" },
    "answers": {
      "full_name": { "label": "Full name", "value": "Emily Carter", "text": "Emily Carter" },
      "email": { "label": "Email", "value": "emily@example.com", "text": "emily@example.com" },
      "role": { "label": "Role", "value": "performer", "text": "Performer" },
      "days": { "label": "Which days are you coming?", "value": ["sat", "sun"], "text": "Saturday, Sunday" },
      "tax_id": { "label": "Tax ID", "value": "ending 6789", "text": "ending 6789", "masked": true },
      "headshot": {
        "label": "Headshot",
        "value": [{ "name": "emily-carter-photo.jpg", "type": "image/jpeg", "size": 482113,
                    "url": "https://app.xenpai.app/dl/…", "expires_at": "2026-10-05T15:06:12Z" }],
        "text": "emily-carter-photo.jpg"
      }
    },
    "payment": { "status": "paid", "amount": 250, "currency": "USD", "paid_at": "2026-10-02T15:07:40Z" },
    "files": [
      { "question": "Headshot", "field": "headshot", "name": "emily-carter-photo.jpg", "type": "image/jpeg",
        "size": 482113, "url": "https://app.xenpai.app/dl/…", "expires_at": "2026-10-05T15:06:12Z" }
    ]
  }
}
  • id is this delivery's id. It stays the same when a delivery is retried, so you can skip one you already have.
  • answers has every question by its key: its label, the value as stored, and text, the same as people read it. Values are text, numbers, true/false, lists of choices, an object for names and addresses, a list of items for repeating questions (each item an object by question key), and files as a list of links.
  • Sensitive answers (tax IDs, bank details, ID numbers, dates of birth) are sent as “ending 6789” with masked: true, unless the owner chose to send them in full.
  • File links download the file without signing in and stop working after 3 days (expires_at). Download what you need to keep.
  • status and payment appear when the form has them. A test sent from Xenpai has "test": true.

Headers

HeaderValue
Content-Typeapplication/json
User-AgentXenpai-Webhooks/1.0
Xenpai-EventThe event, e.g. submission.created
Xenpai-DeliveryThe delivery's id (the same as id in the message)
Xenpai-Signaturet=<unix time>,v1=<signature>

Checking it's from Xenpai

Each address has its own signing secret (it starts with whsec_). The project's owner finds it in Xenpai under the project's Setup → Other apps. The signature v1 is the HMAC-SHA256, in hex, of the time t, a dot, and the request body exactly as received. Compute the same, compare them in constant time, and refuse messages more than 5 minutes old.

import crypto from "node:crypto";

// rawBody: the request body exactly as received (before JSON parsing).
function isFromXenpai(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const age = Math.abs(Date.now() / 1000 - Number(parts.t));
  if (!parts.t || !parts.v1 || age > 300) return false; // older than 5 minutes
  const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
import hashlib, hmac, time

def is_from_xenpai(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t, v1 = parts.get("t", ""), parts.get("v1", "")
    if not t or not v1 or abs(time.time() - int(t)) > 300:  # older than 5 minutes
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)

If the owner makes a new secret, messages are signed with it straight away, so update it on your side at the same time.

Answering, and retries

  • Answer with any 2xx status within 10 seconds. Do slow work after answering.
  • If your app is busy or down (a timeout, 5xx, 408 or 429), Xenpai tries again after about 30 seconds, 2 minutes, 10 minutes and 30 minutes. After the fifth try it stops and tells the project's owner, who can send it again from Xenpai.
  • Other 4xx answers (such as 404 for an address that no longer exists) stop at once and tell the owner, since trying again wouldn't help.
  • Redirects aren't followed: give Xenpai the final address.
  • Messages can arrive more than once or out of order; use id to skip repeats and sent_at to order them.

Addresses

Addresses must start with https:// and be on the public internet. Xenpai doesn't send to private or internal networks, and checks this every time it connects.