Developers

Your leads, out of here and into whatever you build.

A small read API and two signed webhooks. Enough to get every lead and its consent record into your own systems the moment it is captured, and nothing you have to poll for.

Business tier · https://echofair.app/api/v1

Why this exists

Because they are your leads.

The CRM we do not support

We connect to five. There are hundreds, and a good number of exhibitors run something internal. The API is how the sixth one works without us writing it.

Seconds, not overnight

A webhook fires as the lead lands. A salesperson can be called while the visitor is still walking the hall, which is a different product from a spreadsheet on Friday.

Not being locked in

We are asking you to put your customer data in our box. An advertised way out is the thing that makes that a reasonable request rather than a gamble, and it should exist whether or not you use it.

The API is deliberately read-only and small. There is no endpoint to create a lead, because a lead without a consent record is the one thing this product exists not to produce — capture goes through the station or the self-registration page, both of which write the consent trail. If you find yourself polling /leads in a loop, use a webhook instead: that is what it is for.

Authentication

One key, sent as a bearer token.

Make one at Settings → API. It is shown once and stored as a hash, so there is no endpoint that could reveal it again — if you lose it, revoke it and make another.

curl https://echofair.app/api/v1/events \
  -H "Authorization: Bearer x360_your_key_here"

X-Api-Key: x360_… works too, if a bearer header is awkward in whatever you are using. Keys carry scopes — events:read and leads:read — and a key missing one gets a 403 missing_scope rather than an empty list.

Endpoints

Two of them.

That is not a roadmap gap. Reading the events and reading their leads is the whole surface a lead-capture integration needs; everything else is a webhook.

GET /api/v1/events scope events:read

Every event on the account, newest first. Use it to find the id you need for the leads endpoint. No pagination — nobody has enough events for that to matter.

{
  "data": [
    {
      "id": "652f1a...",
      "name": "Spring Trade Fair 2026",
      "status": "active",          // draft | active | closed | archived
      "tier": "business",
      "venue": "Exhibition Centre",
      "city": "Lyon",
      "show_start": "2026-11-20T00:00:00.000Z",
      "show_end": "2026-11-23T00:00:00.000Z",
      "leads": 312
    }
  ]
}
GET /api/v1/events/{id}/leads scope leads:read

The leads for one event, oldest first, each with its consent record attached. Blocked leads — the ones over a tier's quota — are not included.

QueryDefault
limit 100 Capped at 500.
since — ISO timestamp. Returns leads captured strictly after it — pass back the next_since from the previous page.
verified — true returns only leads whose consent has been confirmed. If you are going to email these people, this is the one you want.
curl "https://echofair.app/api/v1/events/652f1a.../leads?verified=true&limit=200" \
  -H "Authorization: Bearer x360_your_key_here"
{
  "data": [
    {
      "id": "6530bb...",
      "event_id": "652f1a...",
      "captured_at": "2026-11-21T09:04:11.000Z",
      "source": "device",          // device | self
      "rep": "Alex M.",
      "contact": {
        "name": "Sam Carter",
        "email": "sam.carter@example.com",
        "phone": "+441632960123",
        "company": "Northwind Hotels"
      },
      "products": ["range_a"],
      "answers": { "notes": "Asked about the conference rooms" },
      "form_version": 2,
      "status": "new",
      "tags": [],
      "consent": {
        "method": "email_doi",
        "text_version": "xenia-2026-v2",
        "text": "We will email you to confirm...",
        "captured_at": "2026-11-21T09:04:11.000Z",
        "confirmed_at": "2026-11-21T09:06:02.000Z",
        "unsubscribed_at": null
      }
    }
  ],
  "has_more": false,
  "next_since": "2026-11-21T09:04:11.000Z"
}

The consent record travels with the lead rather than sitting behind a second call. The whole pitch is that a contact arrives with its proof attached, and an integration that has to ask twice is one that will end up not asking. confirmed_at being null means they have not clicked the link yet — that is not a contact you should be emailing.

Webhooks

So you never have to poll.

Add an https endpoint at Settings → API and we post to it. You get the signing secret once, at the moment you create it.

lead.created

A lead has been captured and stored. Fired on sync from a device and on a self-registration. Consent is not confirmed yet — this is "someone is at the stand", not "you may email them".

{
  "lead_id": "6530bb...",
  "event_id": "652f1a...",
  "captured_at": "2026-11-21T09:04:11.000Z",
  "contact": { "name": "...", "email": "...",
               "phone": "...", "company": "..." },
  "products": ["range_a"],
  "source": "device"
}
lead.verified

The visitor followed the link in their confirmation email. This is the one to act on if what happens next involves contacting them. Only fires on events set to confirmed consent — on a notified event there is no click to wait for, and contactable_at on the lead is the field that answers the same question.

{
  "lead_id": "6530bb...",
  "event_id": "652f1a...",
  "email": "sam.carter@example.com",
  "confirmed_at": "2026-11-21T09:06:02.000Z",
  "text_version": "xenia-2026-v2"
}
lead.unsubscribed

The visitor asked to be left alone, from the link in their email or from their mail client's own unsubscribe button. Stop sending. We already exclude them from every export, CRM sync and campaign of ours, but anything you have copied into your own system is yours to stop.

{
  "lead_id": "6530bb...",
  "event_id": "652f1a...",
  "email": "sam.carter@example.com",
  "unsubscribed_at": "2026-11-23T18:22:40.000Z"
}
Consent modes

Each event is either confirmed — the visitor clicks a link before you may contact them — or notified, where the tick at the stand is the consent and the email announces it. A lead carries both fields either way:

"contactable_at":  "2026-11-22T09:04:11.000Z",
"unsubscribed_at": null,
"consent": { "method": "form" }
// method: "email_doi" | "form"
// contactable_at is null until you may
// send. Never send on capture alone.

The envelope

POST https://your-endpoint
Content-Type: application/json
X-Echofair-Event: lead.verified
X-Echofair-Signature: t=1763715962,v1=8f3a...

{ "id": "evt_9c1d...", "type": "lead.verified",
  "created": 1763715962, "data": { ... } }

Verifying it

Signed the way Stripe signs theirs, because the guarantee you need is the one we demand from them: an HMAC-SHA256 over {timestamp}.{raw body}. Check the signature and the age — the timestamp is what stops a captured request being replayed at you later.

const crypto = require('crypto');

// The RAW body, before any JSON parsing. Re-serialising changes the
// bytes and the signature will never match.
app.post('/hooks/echofair', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('X-Echofair-Signature') || '';
  const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=')));

  const expected = crypto
    .createHmac('sha256', process.env.ECHOFAIR_WEBHOOK_SECRET)
    .update(`${t}.${req.body}`)
    .digest('hex');

  // Constant time: a plain === leaks the signature one byte at a time.
  const ok = v1 && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
  if (!ok) return res.status(400).send('bad signature');

  // Older than five minutes is a replay, however valid the signature.
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.status(400).send('too old');

  const event = JSON.parse(req.body);
  // Answer immediately; do the work afterwards. We time out at 5 seconds.
  res.sendStatus(200);
});

Delivery, retries and parking

Timeout5 seconds. Answer 2xx first and do the work after.
RetriesImmediately, then +2s, then +10s. Only on 5xx and network errors — a 4xx will not become a 2xx on a retry.
ParkingTen consecutive failures and the endpoint is switched off, with the reason shown in Settings. A broken staging URL must not become a queue that grows all show.
OrderingNot guaranteed. lead.verified can arrive before you have finished handling its lead.created — key on lead_id.
DuplicatesPossible, if your endpoint answers slowly and we retry. Every event carries a unique id; discard one you have already seen.

Errors

Every failure is JSON with a stable error code. Read the code, not the message — the message is for a person and may be reworded.

401missing_api_key
401invalid_api_keyUnknown, or revoked.
403missing_scope
403tier_no_apiThat event is not on Business.
404event_not_found
429rate_limited

Limits

Rate120 requests a minute, per key. RateLimit-* headers come back on every response.
Page size500 leads maximum.
TierKeys work on the account; the leads endpoint checks the event is Business.
VersioningEverything lives under /v1. Fields get added; nothing already there is removed or retyped without a /v2.

Something missing?

The surface is small on purpose, but if you are building something real and it does not fit, that is worth a conversation rather than a workaround.

hi@echofair.app