Event registration

Event registration7 min read

Event registration without Eventbrite, for free community events

Run registration for a free community event on your own site: session choices, a confirmation email from your own tool, room counts and a check-in list.

Preet SutharFounder, Ikiform

For a free event, a registration form on your own site can replace Eventbrite's signup: it posts to Ikiform, which stores each registration and emails your team. Add a webhook that sends the confirmation email from your own email provider, and count seats per session from the latest registration per email. Keep Eventbrite, or another ticketing platform, for paid tickets, hard capacity caps and check-in scanning.

What's different without a ticketing platform

Eventbrite does five jobs at once, and it's easy to forget the ones you'll now do yourself. For a free community day, a meetup series or a workshop, most of them are small. Here's the split, honestly:

Which jobs move from Eventbrite to your own setup, and who does eachevent pagetheir page, theirbrandyour pageregistrationtheir checkoutyour form, Ikiformstores itconfirmation emailsent for youwebhook to youremail toolcapacityhard cap, waitlistyou count and pausecheck-inapp, QR scanprinted listjobon Eventbriteon your own siteWhich jobs move from Eventbrite to your own setup, and who does eacheventpagetheirpage,theirbrandyourpageregistrationtheircheckoutyourform,Ikiformstoresitconfirmationemailsentfor youwebhookto youremailtoolcapacityhardcap,waitlistyoucountandpausecheck-inapp, QRscanprintedlistjobonEventbriteon your ownsite
  1. The event page moves from Eventbrite's page to your own site, with your design.
  2. Registration moves from Eventbrite's checkout to your form, which posts to Ikiform.
  3. The confirmation email is no longer sent for you. A webhook sends it from your own email provider.
  4. Capacity is no longer a hard cap. You count registrations per session and pause the form when you're full.
  5. Check-in is no longer an app with QR scanning. You print a list from the CSV export.
The first three are better on your own site. The last two are worse, and for a large or paid event they're the reason to stay on a ticketing platform.

Eventbrite's pricing changes, so check its own pricing page before you compare costs. For free events the reasons to leave are usually not fees: the attendee list sits on someone else's platform, the page looks like theirs, and registrants hand their details to another company with its own account and email settings. For paid events, stay. Ikiform has no payments.

A registration form with session choices

A community day often has parallel sessions, and rooms differ in size. Ask which sessions people plan to attend, so you can move a popular workshop to a bigger room before the day. This definition caps choices at three of four, since two run at the same time:

Preview

Runs Ikiform's validator in your browser. Nothing is sent.

Sessions you'll attend
Lunch
sessions is a multi_select, stored as an array. In HTML, name the checkboxes sessions[] so a single choice is still stored as a list.
  • name is labelled for the badge, which gets you the name people want to be called.
  • lunch is a radio with a No lunch option, so you order for the people who'll eat.
  • updates is an unticked checkbox, stored as true or false on a submission that keeps its own created_at. Only add people to your newsletter when it's true.
  • Use the RSVP form from the main guide for the markup in your stack, with these fields swapped in.
If a registration form also offers future updates, keep that checkbox unticked by default and say exactly what you'll send. The checkbox is your record of consent; the confirmation email below isn't marketing and doesn't need it.

Send the confirmation from your own email tool

Ikiform doesn't email the person who fills in the form. Notification emails go to you and your team only, so there is no autoresponder, confirmation email or double opt-in. Send those from your email tool, triggered by a webhook. For an event that matters, because people forward the confirmation to their manager and search for it on the morning. This route handler receives Ikiform's signed webhook and sends the email through Resend's API. Any provider with a send endpoint works the same way.

app/api/registered/route.ts
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

const TITLES: Record<string, string> = {
  keynote: "Opening talk, 10:00",
  workshop_a: "Workshop A, 11:30",
  workshop_b: "Workshop B, 11:30",
  panel: "Closing panel, 15:00",
};

function escape(text: string) {
  return text.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
}

export async function POST(request: Request) {
  const payload = await request.text();
  try {
    const event = await verifyWebhook({
      payload,
      headers: request.headers,
      secret: process.env.IKIFORM_WEBHOOK_SECRET!,
    });
    if (event.type !== "submission.created") return new Response(null, { status: 204 });

    const fields = event.data.fields;
    const email = String(fields.email ?? "");
    const chosen = (Array.isArray(fields.sessions) ? fields.sessions : []).map((id) => TITLES[String(id)] ?? String(id));
    const response = await fetch("https://api.resend.com/emails", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.RESEND_API_KEY}`,
        "Content-Type": "application/json",
        "Idempotency-Key": request.headers.get("webhook-id") ?? event.data.submission_id,
      },
      body: JSON.stringify({
        from: "Community Day <hello@community.example>",
        to: email,
        reply_to: "hello@community.example",
        subject: "You're registered for Community Day, 14 March",
        html: [
          `<p>Hi ${escape(String(fields.name ?? ""))},</p>`,
          "<p>You're on the list for Community Day on 14 March at The Old Library. Doors open at 9:30.</p>",
          `<p>Your sessions: ${chosen.map(escape).join(", ")}.</p>`,
          '<p><a href="https://community.example/day.ics">Add it to your calendar</a>. Can\'t come after all? Reply to this email.</p>',
        ].join(""),
      }),
    });
    if (!response.ok) return new Response(`Email API answered ${response.status}`, { status: 502 });
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.reason, { status: 400 });
    throw error;
  }
}
  • verifyWebhook checks the signature, so nobody else can make your domain send email.
  • Idempotency-Key uses the delivery's webhook-id, which stays the same on every automatic retry and on a manual Retry of a failed delivery, so a delivery whose answer got lost doesn't send a second email.
  • The name and session titles are escaped before they go into HTML. Treat field values as untrusted input.
  • A 502 makes Ikiform retry, so a short outage at the email provider doesn't lose a confirmation. Spam never triggers the webhook, so bots don't make you email strangers.

Add the route under Webhooks in the dashboard and copy its secret into IKIFORM_WEBHOOK_SECRET. Ikiform retries a failed delivery for about 3 days, and the delivery log shows each attempt. Without code, an automation tool such as Zapier or Make can send the same email.

Seats per session

Ikiform won't stop registrations for one session when its room is full, and it can't hide a full option. Count instead, using each person's newest registration, and run it whenever you want a number:

seats.ts
import { createClient } from "@ikiform/sdk";

const ikiform = createClient({ apiKey: process.env.IKIFORM_SECRET_KEY! });
const ROOMS: Record<string, number> = { keynote: 200, workshop_a: 30, workshop_b: 30, panel: 200 };
const latest = new Map<string, string[]>();

for await (const submission of ikiform.submissions.iterate({ formId: process.argv[2], status: "received", limit: 100 })) {
  const email = String(submission.fields.email ?? "").trim().toLowerCase();
  const chosen = Array.isArray(submission.fields.sessions) ? submission.fields.sessions.map(String) : [];
  if (email && !latest.has(email)) latest.set(email, chosen);
}

for (const [session, seats] of Object.entries(ROOMS)) {
  const taken = [...latest.values()].filter((chosen) => chosen.includes(session)).length;
  console.log(`${session.padEnd(12)} ${String(taken).padStart(4)} of ${seats}${taken >= seats ? "  FULL" : ""}`);
}

When a workshop fills, edit the page to mark it full and remove its option from the form and the field definition, so new posts can't choose it. When the whole day fills, choose Pause form: posts get 403 form_paused, and the page should say registration has closed.

SituationWhat to do
One workshop fullRemove its option from the form and the field definition, and say so on the page
The whole day fullPause form and replace the form with a waitlist email address
Someone cancelsThey reply to the confirmation email. Delete their submission so the count frees the seat
No-shows expectedFree events often see a share of no-shows. Track your own rate and over-register a little next time

At the door

  1. The evening before, export Submissions as CSV and remove duplicate emails, keeping the newest.
  2. Sort by name, keep name, organisation and lunch, and print two copies for the desk.
  3. Print badges from the same sheet with a mail merge, using the name column.
  4. Write in walk-ins by hand. After the event, add them to your own list only if they agree.

Check it before you announce

  1. Under the form's Allowed domains, add your site's domain, plus localhost while you develop. Leave it empty to accept posts from anywhere.
  2. Register with your own email, choosing two sessions.
  3. The confirmation email arrives from your domain, listing both sessions.
  4. If you added a webhook, open the endpoint in Webhooks. The delivery shows succeeded with your server's answer.
  5. Run the seat count. Each of your two sessions shows one seat taken.
  6. Nothing arrived? Switch the dashboard's status filter to Spam. Spam gets the same success answer as a real submission.

Gotchas

ProblemWhat to do
422 saying to choose at most 3 optionsSomeone picked all four sessions. That's the max_items rule working; show Ikiform's message next to the checkboxes.
Only one session is stored when someone picks oneName the checkboxes sessions[]. With the field defined as multi_select, a single value is still stored as a list.
No confirmation email, but the registration is in IkiformOpen the delivery in the endpoint's log. A 502 with the provider's status means it refused the email, often an unverified sending domain.
The submission is in the dashboard but no email or webhook arrivedIt was stored as spam. Spam sends nothing. Open it under the Spam filter to see the reasons, and mark it as not spam to send its email and webhooks.
429 rate_limitedOne visitor sent more than the form's rate limit allows. Standard allows 5 a minute per visitor. Wait for Retry-After, or choose Relaxed or Custom for forms that post from one shared IP.

Questions

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.