Implementing a pre-call attendant list with Daily’s video API

Imagine: You have a scheduled video meeting with a group of people that you’re not too familiar with. You join, and there is one other person there. Someone you don’t know well (or at all). You make awkward small talk while waiting for others to join. Or worse, they are joining on a 4G connection from a cave and you spend a few minutes trying to communicate through “Can you hear me now?”, “Maybe try turning your video off.”, or “How far are you from the cave entrance?”

This could all be avoided if you wait until more people are in the call before joining so that you can blend into the crowd.

To facilitate this act of social self-preservation, the video call app you’re joining needs to show how many participants are already in the call before you join.

That’s what we’ll be building today with Daily Prebuilt, Daily’s full-featured video call embed.

What we’re building

The app contains two main client-side components:

  1. An entry page, where a user can create and enter a Daily video call room:
Daily Prejoin Presence demo entry page
Entry page
  1. A call page, which contains a video call frame on the left and a sidebar on the right:
Participant in a Daily pre-join lobby, with existing participants shown on the right
Pre-join lobby showing existing participants on the right

The sidebar contains a link to invite others to join the call. Underneath that, there is a “participants” list. This list is populated when the participant enters the call’s pre-join lobby, and can be updated by refreshing the page.

Now that we know what we’re building, let’s go through the core technical components.

Technical components

On the client side, I’m using Daily’s Client SDK for JavaScript by importing our daily-js NPM package via a script tag. The client portion is written in vanilla JavaScript.

For creating a Daily room and retrieving the list of attendants, I’m using two static Netlify functions written in Go. However, you’ll find this post useful even if you’re implementing this feature in JavaScript or another language, since the relevant Daily REST API endpoints remain the same regardless of language. They are:

  • /rooms to create a room. I won’t focus too much on this part, because the focus in this post will be presence retrieval.
  • /rooms/:room/presence to retrieve presence information about the room.

Running the demo locally

You can follow along with the tutorial on GitHub, but if you want to run the demo locally, use the following steps:

  1. Sign up for a free Daily account if you don’t already have one.
  2. Copy your Daily API key from the developer dashboard.
  3. Install Go if you don’t already have it installed.
  4. Clone the demo repository.
  5. Copy the sample.env file from the repo root into a new .env file and paste the API key you copied as the value for DAILY_API_KEY. Do not submit this file to version control!
  6. Run npm i && npm run dev

Your default browser should open the web app automatically. If it doesn’t, navigate there manually using the localhost port shown in your terminal, which is usually http://localhost:8888/.

Now that you have the app up and running, let’s go through the implementation.

Defining the DOM

The entry point for the demo is /src/index.html, with the most interesting part being the call containers and daily-js import:

    <div id="container">
      <div id="entry">
        <button id="createRoom">Create Room</button>
      <div id="call" class="hidden">
        <div id="callFrame"></div>
        <div id="sidebar">
          <div id="invite">Invite URL:
            <br /><a id="inviteURL" href=""></a></div>
          <hr />
          <div id="presence" class="hidden">
            The following participants are already in the call:
            <ul id="participants"></ul>

    <script crossorigin src=""></script>
    <script src="./call.js" type="module"></script>

Above, I’m creating an "entry" div that has a single room creation button.

I’m then defining a "call" div, which is where the Daily Prebuilt call frame will live once a call is joined. This also contains the sidebar element with the invite URL and presence list.

Finally, I’m adding two script tags: one to import daily-js and another to import the local JavaScript entry point: /src/call.ts.

Let’s take a closer look at that call file next.

Instrumenting the DOM

When the DOM is loaded, set up the two main lobby flows:

  • Call creation: The participant is creating a new room and joining it.
  • Call join (via query parameters): The participant is joining an existing Daily room via a link shared from another participant already in the call.

The logic looks as follows:

window.addEventListener('DOMContentLoaded', () => {

  // If room URL and name params were provided, join that room
  const params = new URLSearchParams(;
  const roomURL = params.get('roomURL');
  const roomName = params.get('roomName');
  if (roomURL && roomName) {
    joinRoom(roomURL, roomName);

  // If room URL and name params were not provided, show
  // entry element (with call creation button)

Let’s go through an overview of both flows now.

Creating a Daily video call room

A Daily room is created when the application user clicks the “Create Room” button on the demo home page:

Room creation button
Room creation button

As I said, I won’t focus too much on this part of the process here. Let’s go through just a high-level overview.

  • When the index page loads, I set up an onclick handler for the room creation button above via setupCreateButton().
  • When this button is clicked, the createRoom() function in call.js will be invoked:
async function createRoom() {
  const url = `/.netlify/functions/createRoom`;
  const errMsg = 'failed to create room';

  // Create Daily room
  let res;
  try {
    res = await fetch(url);
  } catch (e) {
    console.error('failed to make Daily room creation request', e);

  // Retrieve body, if it exists in the response
  let body;
  try {
    body = await res.json();
  } catch (e) {
    console.warn('No body present in room creation response');

  // Check status code
  if (res.status !== 200) {
    const msg = `${errMsg}. Status code: ${res.status}`;
    console.error(`failed to create Daily room: ${msg}`, body);

This will, make a POST request to my createRoom Netlify function endpoint, the definition of which can be found in /.netlify/functions/createRoom/main.go.

I won’t go through the whole server-side creation flow here, but a thing worth noting is that I generate each room with a very important property: enable_prejoin_ui: true.

This property will ensure a participant won’t land straight in the meeting when they call Daily’s join() call frame instance method. They’ll instead see a pre-join UI where they can select their devices, test their camera, and—critically—see who’s already in the call:

Screenshot of existing participants list
Participants list

With that said, let’s go ahead and cover the implementation of the join flow and presence data retrieval.

Joining the Daily video call

As we already saw in the overview above, there are two paths to joining the Daily call in this demo:

  • The call creator joins the call after creating a room.
  • A call participant joins the call directly when certain query parameters are passed in the URL (namely roomURL and roomName.

Both of these scenarios go through the same joinRoom() function:

function joinRoom(roomURL, roomName) {

  // Set up the call frame and join the call
  const callContainer = document.getElementById('callFrame');
  const callFrame = window.DailyIframe.createFrame(callContainer, {
    showLeaveButton: true,
  callFrame.on('left-meeting', () => {

  callFrame.join({ url: roomURL });

  updateDisplayedURLs(roomURL, roomName);

  // Fetch existing participants and show them next to the call frame

Alright, let’s go through what’s happening above:

  1. First, I show the call div I created in index.html by calling showCall() in dom.js. This function also makes sure the original entry element (with the call creation button) is hidden, since we don’t need it when the user enters a video call.
  2. Next, I instantiate the Daily Prebuilt call frame within my call container div. I do this by calling the daily-js createFrame() factory method and passing the call container object to it.
  3. I set up an event handler for Daily’s ’joined-meeting’ event. This event will fire when the local participant joins the call (not just the prejoin lobby, but the video call itself). When this happens, I’ll hide the presence div - the participant will be able to see who’s in the call through Prebuilt’s own UI one they’re in.
  4. Next, I call the join() call frame instance method. This will join the given Daily room URL. However, note that if we have Prebuilt’s pre-call lobby feature enabled (which in this case, we do), this call with result in them joining the lobby.
  5. Then, I update the DOM with an invite URL to this call.
  6. Finally, I call fetchParticipants(), passing the room name as a parameter. This is where the magic happens.

Let’s go through the participant fetching next.

Fetching video call presence from Daily

On the client, fetching a list of participants in the Daily room looks as follows:

async function fetchParticipants(roomName) {
  const url = `/.netlify/functions/presence?`;
  const errMsg = 'failed to fetch room presence';

  let res;
  try {
    // Add room name to the presence endpoint query parameters
    const reqURL =
      url +
      new URLSearchParams({

    // Call out to the Netlify presence endpoint
    res = await fetch(reqURL);
  } catch (e) {
    console.error('failed to make Daily room presence request', e);

  // Retrieve body from response, if it exists
  let body;
  try {
    body = await res.json();
  } catch (e) {
    console.warn('No body present in room presence response');

  // Check status code
  if (res.status !== 200) {
    const msg = `${errMsg}. Status code: ${res.status}`;
    console.error(`failed to fetch presence for Daily room: ${msg}`, body);

  // Count the number of participants in the returned body
  const count = body.length;
  if (count === 0) {
    // If no one is there yet, update the UI to state that
    addToPresenceList('Nobody here yet!');
  } else {
    // If there is someone in the room, add their
    // username or ID to the presence list in the DOM
    for (let i = 0; i < body.length; i += 1) {
      const p = body[i];
      const label = p.userName ? p.userName :;
  // Show the presence DOM element

The in-line comments above guide you through exactly what the function is doing, but in short: I call out to my presence Netlify function endpoint to fetch a list of participants already in the room. I then update the UI accordingly and show the presence DOM element to the user.

But how is that list of participants actually fetched? Let’s hop over to the server to find out.

Fetching presence information from Daily’s REST API (with Go!)

The Netlify function for fetching Daily presence can be found in the netlify/functions/presence/main.go file within the demo repo. I suggest checking that out to see the full context. Let’s cover the most important part below: the logic to actually call out to Daily’s REST API for presence information.

Before I show you the code, let’s cover roughly what we’re going to do:

  1. Make a call to Daily’s rooms/:name/presence REST API endpoint for presence data
  2. Parse the response data into a byte slice.
  3. Return an error (with the body, if relevant) if the response code is not 200.
  4. Otherwise, unmarshal the body as JSON into a pre-defined struct to hold all the presence data.

Let’s start with the primary building blocks - understanding the format in which Daily’s REST API will return presence data. For this, we can refer to the documentation for this endpoint as an example:


With that in mind, I can define a Go struct that will follow the above data format:

// presenceRes is the expected response format
// from Daily's REST API when retrieving room presence.
type presenceRes struct {
	Data []Participant `json:"data"`

Well, that’s not much… As you can see, above we have a struct with a single field defined: Data. The field is a slice of Participant, which is mapped to the "data" JSON property.

In Go, constructs starting with a lowercase letter are considered “unexported” (accessible only by members within the same package/directory). Anything starting with an uppercase letter is “exported” (accessible by all members within the project).

presenceRes above is defined as an unexported struct, but its Data field is exported. Why? json.Unmarshal() only unmarshals exported fields. So even though presenceRes should not be accessible to any package outside of presence, its Data property needs to be exported for unmarshalling purposes.

Now, let’s take a look at the Participant struct definition:

// Participant represents a Daily participant.
// who is already in a Daily room
type Participant struct {
	ID   string `json:"id"`
	Name string `json:"userName"`

As you can see above, each instance of Participant will contain the participant’s ID and name, as returned from Daily. These are the only pieces of data I’ll use for this demo.

Now that we have all the building blocks in place, let’s see what getPresence() looks like:

// getPresence retrieves all participants already in the given Daily room
func getPresence(roomName, apiKey, apiURL string) ([]Participant, error) {
	endpoint := fmt.Sprintf("%s/rooms/%s/presence", apiURL, roomName)
	// Make the actual HTTP request
	req, err := http.NewRequest("GET", endpoint, nil)
	if err != nil {
		return nil, fmt.Errorf("failed to create GET request to room endpoint: %w", err)

	util.SetAPIKeyAuthHeaders(req, apiKey)

	// Do the thing!!!
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("failed to get room: %w", err)

	// Parse the response
	resBody, err := io.ReadAll(res.Body)
	if err != nil {
		return nil, fmt.Errorf("failed to read presence response body: %w", err)

	if res.StatusCode != http.StatusOK {
		return nil, util.NewErrFailedDailyAPICall(fmt.Errorf("%d: %s", res.StatusCode, string(resBody)))

	var pr presenceRes
	if err := json.Unmarshal(resBody, &pr); err != nil {
		return nil, fmt.Errorf("failed to unmarshal Daily response to participant slice: %w", err)
	return pr.Data, nil

The above function completes all the steps I outlined in the beginning of this section and returns the Data property of the presenceRes instance I get by unmarshalling the response body back to the caller, which in this case is my endpoint handler.

And that’s it! We’ve now covered both the client and server-side portions of implementing a pre-join presence list for the betterment of video-calling humankind.

What’s next?

There are many ways you can expand and tailor this functionality to your own use case. Just a few ideas to play around with include:

  • Making the list refresh dynamically by refetching the participant list, instead of requiring a page refresh.
  • Showing an avatar next to the participant’s name. Check out our user data documentation, which could come in handy here.
  • Display more details about each participant, such as how long they’ve been waiting for, to application administrators.Check out our guide on using Daily’s meeting tokens as a starting point for this feature.


In this post, we went through a basic implementation of pre-join video call presence display using Daily’s WebRTC API platform.

Armed with this knowledge, you have the power to help introverted call users everywhere to avoid the awkwardness of being the second person joining a group call.

Don’t hesitate to reach out to our support team if you’d like any help implementing pre-join presence in your own video app. Or head over to peerConnection, our WebRTC community to keep the conversation going.

Never miss a story

Get the latest direct to your inbox.