event67
Open menu

Webhooks

Register an HTTPS endpoint and event67 signs and POSTs a small JSON body every time something changes on your event. Deliveries are retried for a day, logged for thirty, and every one of them is signed with a secret only you and we hold.

Registering an endpoint

Endpoints are created in the organizer console under Integrations thenWebhooks, by an org admin. You give it a URL, a description, and the event types you want. An endpoint can be scoped to one event or left to cover every event the organization runs, now and later.

The signing secret is shown once, on the screen that creates the endpoint. It starts withwhsec_. Store it the way you would store a database password; if you lose it, rotate it rather than trying to recover it.

What a URL has to satisfy

We refuse a URL that could be used to make our servers talk to something they should not. The rules are checked when you save the endpoint and again on every single connection, because a hostname that resolved to a public address yesterday can resolve to a private one today.

  • HTTPS only. Plain HTTP is refused outright.
  • A public hostname. The name has to resolve, and every address it resolves to has to be a public one. Loopback, private ranges, link-local addresses including the cloud metadata address, carrier-grade NAT space, IPv6 unique-local, multicast and the unspecified address are all rejected.
  • No credentials in the URL. A user:password@ prefix is refused. Authenticate the delivery with the signature, or with a secret path segment if you must.
  • No redirects. We do not follow them, so a 301 from your endpoint is a failed delivery, not a hop. Register the final URL.
  • Answer within ten seconds with any 2xx. Anything else, including a timeout, counts as a failure and will be retried.

An organization may hold up to 20 endpoints. Response bodies are ignored; on a failure we keep the first 512 characters of what you sent back, so a short plain-text reason is genuinely useful when you are reading the delivery log later.

The event catalogue

Choose the types you want, or subscribe to everything with * and filter on your side. Subscribing to * also means you receive types added in future releases, which is convenient and is also a reason to ignore a type you do not recognise rather than erroring on it.

TypeSent when
pingYou press Send test in the console. Never sent by anything else, so a handler can ignore it or use it as a health check.
event.updatedAn event's name, dates, timezone or location changed.
event.publishedAn event went live for attendees.
attendee.createdAn attendee was added, however it happened: the console, an import, the Partner API or a data source.
attendee.updatedAn attendee's profile details changed.
attendee.deletedAn attendee was removed. The body carries the record as it was immediately before removal.
attendee.checked_inAn attendee arrived at the venue. First arrival only; a second scan of the same badge changes nothing and sends nothing.
session.createdA session was added to the programme.
session.updatedA session moved, was renamed, or changed room.
session.deletedA session was removed. The body carries it as it was immediately before removal.
session_attendance.createdAn attendee was scanned into a session at its door. First scan of that badge for that session only.
form.submission.createdAn attendee submitted a form. A resubmission sends this again with a higher revision.
feedback.createdAn attendee rated or commented on the event or one of its sessions.

The console's event-type picker is generated from the same catalogue and shows a sample payload for each type, so it and this page cannot drift apart.

What a delivery looks like

POST /hooks/event67 HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: event67-webhooks/1
X-Event67-Event: attendee.created
X-Event67-Event-Id: 7c9a3e51-40b8-4d26-9f13-6ea205c8b7d4
X-Event67-Delivery: 2b60e419-8f3d-4c07-a51e-d9714b0c8236
X-Event67-Signature: t=1789041600,v1=9f2c0b7e4a1d83f6c5e0927ab314d8067fe25c9130ab4d7e8f6c02b195a7d34e
HeaderWhat it carries
X-Event67-EventThe type, matching the type member of the body.
X-Event67-Event-IdThe event's own id. Stable across retries, and the value to deduplicate on.
X-Event67-DeliveryThis attempt's delivery id, one per endpoint per event. Useful for quoting a specific delivery back to us.
X-Event67-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>. See below.

The body is always the same shape: an envelope identifying the event, plus a datamember holding the resource it is about. eventId is null on a delivery that is not about one particular event.

{
  "id": "7c9a3e51-40b8-4d26-9f13-6ea205c8b7d4",
  "type": "attendee.created",
  "createdAt": "2026-09-09T10:00:00.000Z",
  "orgId": "0f9c41d2-6b1e-4a37-9d55-2c8ab0e71f34",
  "eventId": "8d2f0a16-4e93-4b28-b0c7-1f5e9a3d6c40",
  "data": {
    "id": "f13c6b28-9a05-4d71-b6e4-20c8f5a9d374",
    "eventId": "8d2f0a16-4e93-4b28-b0c7-1f5e9a3d6c40",
    "badgeCode": "A7K3QS",
    "displayName": "Dana Okonkwo",
    "email": "dana@example.com",
    "company": "Meridian Logistics",
    "jobTitle": "Head of Operations",
    "status": "invited",
    "visibleInList": true,
    "invitedAt": "2026-09-09T09:59:58.412Z",
    "checkedInAt": null
  }
}

A test ping carries a fixed message rather than a resource.

{
  "id": "0b7f4c29-8a35-4d61-b0e2-4c19a75f8d36",
  "type": "ping",
  "createdAt": "2026-09-09T11:20:04.771Z",
  "orgId": "0f9c41d2-6b1e-4a37-9d55-2c8ab0e71f34",
  "eventId": null,
  "data": { "message": "This is a test delivery from event67." }
}

Where a payload differs from the API

A delivery's data is the same projectionthe Partner API returns for that resource, with four documented exceptions and one shape that exists only here. A webhook is emitted from the workflow that made the change, holding what that workflow had; the API is served from a repository holding a database row. Where the emit path cannot supply a field, it is left out rather than guessed, because a timestamp derived from another timestamp is worse than an absent one. You cannot tell that it is wrong.

PayloadWhat is missing, and what to do
attendee.*No createdAt or updatedAt. Every other attendee field is present. Use the envelope's createdAt for when the change happened.
event.*No createdAt or updatedAt, for the same reason.
session.*No speakerIds. Resolving them would be a second query on a path that must never slow down or fail the request that triggered it. Re-read the session fromGET /events/{eventId}/sessions if you need its speakers.
feedback.createdNo attendeeId. The feedback record stores a user id, and turning that into an attendee means reaching across modules from the emit path. The body carries the feedback id, so re-readGET /events/{eventId}/feedback when you need the person.
form.submission.createdA shape of its own, below. It is not the Partner API's FormSubmission.

A form submission is the one payload with no Partner API counterpart. The public submit path upserts and answers with a revision rather than a stored row, so there is no id and no updatedAt to send, and adding a read to an unauthenticated request purely to decorate a webhook is not a trade worth making. The API's ownFormSubmission does carry both; fetchGET /events/{eventId}/forms/{formId}/submissions when you need the stored row. A resubmission arrives as this same type with a higher revision, which is why there is no separate form.submission.updated.

{
  "id": "5d1e8b4a-0c73-4f96-a218-6e93b07d5c41",
  "type": "form.submission.created",
  "createdAt": "2026-10-28T09:12:03.512Z",
  "orgId": "0f9c41d2-6b1e-4a37-9d55-2c8ab0e71f34",
  "eventId": "8d2f0a16-4e93-4b28-b0c7-1f5e9a3d6c40",
  "data": {
    "formId": "3f6c0b95-71ad-4e02-8c14-9b5d7e06a3f8",
    "eventId": "8d2f0a16-4e93-4b28-b0c7-1f5e9a3d6c40",
    "attendeeId": "f13c6b28-9a05-4d71-b6e4-20c8f5a9d374",
    "revision": 1,
    "answers": { "diet": "No nuts" },
    "submittedAt": "2026-10-28T09:12:03.441Z"
  }
}

Verifying the signature

The signature is an HMAC-SHA256, keyed on your endpoint's secret, over the stringt, then a full stop, then the raw request body. Rebuild that string, compute the HMAC, and compare it with v1 in constant time.

Sign the bytes that arrived. The single most common failure here is parsing the JSON, re-serialising it, and hashing that. Key order and whitespace will differ and the signature will never match. Read the raw body first, verify, and only then parse. Reject the request if the timestamp is more than 300 seconds away from now, in either direction: that is what stops somebody replaying a delivery they captured.

Node

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

const SECRET = process.env.EVENT67_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;

export function verify(rawBody, headerValue, secret) {
  const parts = {};
  for (const piece of String(headerValue || '').split(',')) {
    const index = piece.indexOf('=');
    if (index > 0) parts[piece.slice(0, index).trim()] = piece.slice(index + 1).trim();
  }

  const timestamp = Number(parts.t);
  const signature = parts.v1;
  if (!Number.isFinite(timestamp) || !signature) return false;

  // Reject anything too old to be a live delivery, or dated in the future.
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(parts.t + '.' + rawBody, 'utf8')
    .digest('hex');

  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(signature, 'utf8');
  // Length check first: timingSafeEqual throws on a length mismatch.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

// express.raw, NOT express.json. The signature covers the bytes that arrived.
app.post('/hooks/event67', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body.toString('utf8');
  if (!verify(raw, req.get('X-Event67-Signature'), SECRET)) {
    return res.status(400).send('bad signature');
  }

  const event = JSON.parse(raw);
  // Answer fast, then do the work. event67 gives you ten seconds.
  res.status(204).end();
  void handle(event);
});

Python

import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify(raw_body: bytes, header_value: str, secret: str) -> bool:
    parts = {}
    for piece in (header_value or "").split(","):
        key, sep, value = piece.partition("=")
        if sep:
            parts[key.strip()] = value.strip()

    try:
        timestamp = int(parts["t"])
        signature = parts["v1"]
    except (KeyError, ValueError):
        return False

    # Reject anything too old to be a live delivery, or dated in the future.
    if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
        return False

    signed = parts["t"].encode("utf-8") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)


# Flask, with the raw body rather than request.json.
@app.post("/hooks/event67")
def hook():
    if not verify(request.get_data(), request.headers.get("X-Event67-Signature", ""), SECRET):
        return "bad signature", 400

    event = request.get_json()
    enqueue(event)          # Answer fast; ten seconds is the whole budget.
    return "", 204

After rotating a secret, accept either the old or the new one for a few minutes. Deliveries that were already in flight when you rotated will still be on their way.

Duplicates and ordering

Delivery is at-least-once. A network failure after your server committed but before we read your response is indistinguishable from a failure before it, so we retry and you may see the same event twice.

  • Deduplicate on X-Event67-Event-Id, which is also the body'sid. It is stable across every retry of the same event. Record it and ignore an id you have already processed.
  • Do not assume order. Retries mean an attendee.updated can arrive before the attendee.created it followed. Use createdAt to order, or treat every delivery as a hint and re-read the record from the Partner API.
  • Answer first, work second. Ten seconds is the whole budget. Verify, enqueue, return 204. A handler that does its work inline will start timing out on the day the event is busiest.

Retries and auto-disable

A failed delivery is retried six times, at roughly one minute, five minutes, thirty minutes, two hours, eight hours and twenty-four hours after the failure. Seven attempts in all, spanning a day, after which the delivery is marked exhausted and left in the log.

Retries are processed on a five-minute cadence, so those offsets are floors rather than exact times: a retry due at one minute will go out at the next pass, up to five minutes later. Plan for "within a day", not for a schedule.

An endpoint that fails 50 times in a row is disabled automatically and stops receiving anything. Any successful delivery resets the counter to zero, so an endpoint that is merely flaky will never trip it; one that has been pointing at a decommissioned host for a week will.

  • The console shows the endpoint as auto-disabled, with the consecutive-failure count.
  • Re-enabling it from the console clears the counter and resumes deliveries.
  • Deliveries queued while it was disabled stay queued and go out when it comes back, up to the thirty-day retention. Beyond that they are gone and the Partner API is how you catch up.

Testing and the delivery log

  • Send test. The console sends a ping to the endpoint immediately and shows you the status code, the round-trip time and the error text if there was one. Use this before you go anywhere near a live event.
  • The delivery log. Every attempt for an endpoint is listed with its status, attempt number, status code, duration and error, kept for 30 days.
  • Redeliver. Any delivery in the log can be sent again from the console. It resets the attempt counter and goes out immediately, which is what you want after fixing a bug on your side.

If you need to backfill more than thirty days, or you are standing an integration up on an event that already ran, read it out of the Partner API instead. Webhooks are for keeping up, not for catching up.