PassPassPassPass
DOCS
Documentation
Updates
Create event
Home
PassPassPassPass

The ticketing service for event organizers.

Discover PassPass Pro →

Documentation

Quick startAll themesAll articlesUpdates

Quick links

PassPass ProFrequently asked questionsOpen ticketContact us

Quick access

© 2024 PassPass. All rights reserved.
Mentions légalesPassPass - Event ticketing

Webhooks: Technical Reference

Technical reference for receiving and processing PassPass webhooks. Payload structure, permissions, signature, retries.

Last updated : August 7, 2026

This article is intended for the person implementing the reception of PassPass webhooks. For an overview and configuration from the dashboard, see Public API and Webhooks.


Understanding the Data

Each webhook contains one or more data blocks. Here is what each represents.

Order : a transaction made by a buyer. Contains the total amount, payment status, buyer's identity, details of purchased tickets (line_items), and possibly company billing data (billing).

Attendees : one entry per ticket in the order. If a buyer takes 3 tickets, there will be 3 entries in attendees. Each entry contains the ticket type, ticket status, and depending on your permissions: participant contact details, price paid, form responses, and the ticket access token.

Event : basic information about your PassPass event (title, dates, location, status). This block is identical in all types of webhooks.

Billing : company data of the buyer (name, VAT, address). Present only if the buyer checked "I am ordering on behalf of a company" at checkout. Otherwise null.

Form answers : responses to the custom fields you have configured. Present at two levels: at the order level (form_answers in order) and at the participant level (form_answers in each attendee).

Access token : the secret identifier behind a ticket's QR code, read by the PassPass Organizer mobile app during entry control. This token can, for example, be reassigned on a badge to replicate a QR code readable by PassPass ticket control. Be careful when using it: transmitting this token is equivalent to giving access to the ticket.


Available Events

Each type of event sends different data blocks:

  • order.created : order created (before payment) → order, event, attendees

  • order.paid : payment confirmed (also includes completed free orders) → order, event, attendees

  • order.cancelled : order cancelled → order, event, attendees

  • order.expired : cart expired (25 min without payment) → order, event, attendees (empty)

  • ticket.validated : ticket scanned at entry → scan, participant

  • event.published : event published → event

  • event.updated : event updated → event, changed_fields


Permissions (scopes)

The data included in the payload depends on the permissions configured on your endpoint. An unauthorized field is absent from the payload, not null.

  • events:read : event block (title, dates, location, status)

  • orders:read : basic order block (amount, buyer, billing, line items, status)

  • orders:read:financial : service_fee, payout_amount, refundable_amount, historical_service_fee

  • orders:read:form_answers : order form responses

  • attendees:read : minimal participant block (ID, ticket, status, dates)

  • attendees:read:contact : first_name, last_name, email

  • attendees:read:financial : ticket_price

  • attendees:read:form_answers : participant form responses

  • attendees:read:full : access_token (automatically includes contact and financial)

access_token is the secret identifier behind the ticket's QR code. Request attendees:read:full only if your system truly needs it.


Payload Structure

Each webhook is a JSON POST with this envelope:

{
  "type": "order.paid",
  "created_at": "2026-04-17T10:00:00.000Z",
  "data": { }
}

Conventions:

  • All fields are in snake_case

  • Amounts are in euros (not in cents). 25 = 25,00€

  • Dates are in ISO 8601 UTC

  • Multilingual fields are objects {"fr": "...", "en": "...", "nl": "..."}

  • Exception: buyer and billing blocks are in camelCase

  • The payload can receive new fields without notice. Do not reject an unknown field


Complete Example: order.paid

Triggered when a payment is confirmed, or when a free order is finalized. It is the most used event: it confirms that a participant has completed their registration. Use case: add the participant to your CRM, send a welcome email, update your accounting, trigger a Zapier/Make/n8n workflow.

{
  "type": "order.paid",
  "created_at": "2026-04-17T10:00:00.000Z",
  "data": {
    "order": {
      "id": 65817,
      "event_id": 1535,
      "status": "paid",
      "origin": "online",
      "currency": "EUR",
      "amount_total": 200,
      "buyer": {
        "firstName": "Marie",
        "lastName": "Dupont",
        "email": "marie@example.com"
      },
      "billing": {
        "identifierType": "vat",
        "companyName": "Demo Corp SRL",
        "vat": "BE0123456789",
        "vatCountry": "BE",
        "addressStreet": "Rue de la Loi 1",
        "addressCity": "Bruxelles",
        "addressZip": "1000",
        "addressCountry": "BE"
      },
      "line_items": [
        {
          "type": "ticket",
          "ticket_id": 4525,
          "name": {"fr": "Entrée Standard"},
          "quantity": 2,
          "unit_amount": 100,
          "subtotal": 200
        }
      ],
      "form_answers": null,
      "paid_at": "2026-04-17T10:00:00.000Z",
      "cancelled_at": null,
      "created_at": "2026-04-17T10:00:00.000Z",
      "updated_at": "2026-04-17T10:00:00.000Z",
      "service_fee": 4.6,
      "payout_amount": 195.4,
      "refundable_amount": 4.6,
      "historical_service_fee": 4.6
    },
    "event": {
      "id": 1535,
      "title": {"fr": "Mon événement"},
      "short_description": {"fr": "Description courte"},
      "address_label": {"fr": "Bruxelles"},
      "locality_type": "physical",
      "start_at": "2026-06-01T18:00:00.000Z",
      "end_at": "2026-06-01T23:00:00.000Z",
      "timezone": "Europe/Brussels",
      "currency": "EUR",
      "status": "published",
      "visibility": "public"
    },
    "attendees": [
      {
        "id": "019fdb0d-...",
        "event_id": 1535,
        "order_id": 65817,
        "status": "active",
        "ticket": {
          "id": 4525,
          "type": "ticket",
          "name": {"fr": "Entrée Standard"}
        },
        "first_name": "Marie",
        "last_name": "Dupont",
        "email": "marie@example.com",
        "ticket_price": 100,
        "access_token": "019fdb0d-...",
        "created_at": "2026-04-17T10:00:00.000Z",
        "updated_at": "2026-04-17T10:00:00.000Z",
        "form_answers": [
          {
            "type": "selection",
            "field_id": "abc123...",
            "question_title": {"fr": "Quel est votre statut actuel ?"},
            "answer": {"fr": "Indépendant"}
          },
          {
            "type": "unique-choice",
            "field_id": "def456...",
            "question_title": {"fr": "Régime alimentaire"},
            "answer": {"fr": "Végétarien"}
          },
          {
            "type": "short-text",
            "field_id": "ghi789...",
            "question_title": {"fr": "Nom entreprise"},
            "answer": "Demo Corp"
          },
          {
            "type": "formatted-field",
            "field_id": "jkl012...",
            "question_title": {"fr": "Téléphone"},
            "answer": "+32400000000"
          },
          {
            "type": "formatted-field",
            "field_id": "mno345...",
            "question_title": {"fr": "Code postal"},
            "answer": "1000"
          }
        ]
      }
    ]
  }
}

Example: ticket.validated

Triggered when a ticket is scanned via the PassPass Organizer mobile app. Use case: update a real-time attendance board, trigger badge printing, send a notification to your team upon a VIP's arrival.

{
  "type": "ticket.validated",
  "created_at": "2026-04-17T10:00:00.000Z",
  "data": {
    "scan": {
      "id": "019d-...",
      "type": "scan",
      "direction": "enter",
      "created_at": "2026-04-17T10:00:00.000Z"
    },
    "participant": {
      "id": "019fdb0d-...",
      "event_id": 1535,
      "order_id": 65817,
      "status": "active",
      "ticket": {
        "id": 4525,
        "type": "ticket",
        "name": {"fr": "Entrée Standard"}
      },
      "first_name": "Marie",
      "last_name": "Dupont",
      "email": "marie@example.com",
      "ticket_price": 100,
      "created_at": "2026-04-17T10:00:00.000Z",
      "updated_at": "2026-04-17T10:00:00.000Z"
    }
  }
}

Example: event.updated

Triggered when a tracked field of your event is modified (title, dates, location, description, status...). The payload contains the state of the event after modification and the list of changed fields. Use case: synchronize event information on your website, notify your team of a date or location change.

{
  "type": "event.updated",
  "created_at": "2026-04-17T10:00:00.000Z",
  "data": {
    "event": {
      "id": 1535,
      "title": {"fr": "Mon événement (modifié)"},
      "short_description": {"fr": "Nouvelle description"},
      "address_label": {"fr": "Bruxelles"},
      "locality_type": "physical",
      "start_at": "2026-06-01T18:00:00.000Z",
      "end_at": "2026-06-01T23:00:00.000Z",
      "timezone": "Europe/Brussels",
      "currency": "EUR",
      "status": "published",
      "visibility": "public"
    },
    "changed_fields": ["title", "short_description"]
  }
}

Signature Verification

Each request contains a signature header:

x-passpass-signature: t=1776412800,v1=9f2c...

The signature is an HMAC-SHA256 calculated with your secret (whsec_...) on the string <timestamp>.<raw request body>.

Node.js:

const crypto = require('crypto');

function verify(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(
    header.split(',').map((kv) => kv.split('='))
  );
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.${rawBody}`)
    .digest('hex');

  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(parts.v1 || '', 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Python:

import hmac, hashlib, time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(kv.split("=", 1) for kv in header.split(","))
    ts = int(parts.get("t", 0))
    if not ts or abs(time.time() - ts) > tolerance:
        return False
    expected = hmac.new(
        secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

Reject requests with a timestamp older than 5 minutes.


HTTP Headers

Each webhook request contains these headers:

  • x-passpass-event : event type (e.g. order.paid). Allows routing without parsing the body

  • x-passpass-signature : HMAC signature to verify authenticity

  • x-passpass-timestamp : Unix timestamp (seconds) used in the signature

  • x-passpass-idempotency-key : deduplication key. Stable for the same event, including retries. Deduplicate on this field on the receiver side

  • x-passpass-delivery-id : unique delivery attempt identifier. Useful for debugging


Retries and Deactivation

Your endpoint must respond HTTP 2xx in less than 10 seconds. Any other code, a timeout, or a network error counts as a failure.

In case of failure, PassPass retries 6 times with exponential backoff: 1, 2, 4, 8, 16, 32 minutes.

After 20 consecutive failures, the endpoint is automatically deactivated. It must be manually reactivated from your dashboard. A success resets the counter to zero.

Acknowledge receipt immediately (HTTP 200) and process in the background. If processing is long, do not make PassPass wait.


Testing Your Endpoint

From your dashboard (Settings → Developers), click Test on your endpoint. PassPass sends a demo payload to your URL to validate the connection and signature verification.

Test payloads use recognizable identifiers: IDs starting from 9,999,000, email demo@passpass.be, names "Demo ...".

The delivery history is viewable from your dashboard. Each send (successful or failed) is recorded there with your server's response code.


For any technical questions or need for support, feel free to contact our support.

Contact us

Related articles

3 articles in this category