Build a Speech-to-Text Web Application with Rev AI and PHP (Part 2)
Tags: php, api, tutorial, speechrecognition
Published: 2022-09-26 14:00:24

By Vikram Vaswani, Developer Advocate
_This tutorial was originally published at https://docs.rev.ai/resources/tutorials/build-speech-to-text-web-application-php-2/ on Sep 08, 2022._

## Introduction

Rev AI's [automatic speech recognition (ASR) APIs](https://docs.rev.ai/api/) enable developers to integrate fast and accurate speech-to-text capabilities into their applications. The first part of this tutorial introduced you to Rev AI's [Asynchronous Speech-to-Text API](https://docs.rev.ai/api/asynchronous/). It explained how to record audio through a Web browser and submit the audio to Rev AI for transcription via the API using the Guzzle PHP HTTP client.

At the end of the [first part of this tutorial](https://docs.rev.ai/resources/tutorials/build-speech-to-text-web-application-php-1/), the example application was able to submit audio to Rev AI, but you still had to use the Rev AI dashboard to manually retrieve your transcripts. This concluding segment closes the loop, explaining how to retrieve transcripts from Rev AI using a webhook and display them in the example application. It will also explain how to add transcript deletion and search functions to the Web application.

NOTE: The [complete source code for the example application is available on GitHub](https://github.com/revdotcom/revai-examples/tree/master/php_examples/voicenotes), so you can download and try it immediately.

## Assumptions

This tutorial assumes that:
- You have a Rev AI account and access token. If not, [sign up for a free account](https://www.rev.ai/auth/signup) and [generate an access token](https://docs.rev.ai/get-started.md#step-1-get-your-access-token).
- You have Docker installed. If not, [download and install Docker](https://docs.docker.com/get-docker/) for your operating system.
- You have Docker Compose installed. If not, [install Docker Compose](https://docs.docker.com/compose/install/) for your operating system.
- You will deploy the application's webhook at a public URL. If not, or if you prefer to develop and test locally, [download and install `ngrok`](https://ngrok.com/) and [obtain an `ngrok` authentication token](https://dashboard.ngrok.com/signup). You will need this to generate a temporary public URL for the webhook.

Any application that uses the Rev AI APIs must also comply with Rev AI's [API limits](https://docs.rev.ai/api/asynchronous/#api-limits) and [terms of service](https://www.rev.ai/about/terms). Before proceeding, please review these documents and ensure that you are in agreement with them.

NOTE: This tutorial uses a Docker-based Apache/PHP/MongoDB development environment. If you already have a properly-configured development environment with [Apache 2.x](https://httpd.apache.org/), [PHP 8.1.x](https://www.php.net/) with the [MongoDB extension](https://www.mongodb.com/docs/drivers/php/) and [Composer](https://getcomposer.org/), you can use that instead. You may need to replace some Docker commands with equivalents.

## Step 1: Receive transcripts from Rev AI using a webhook

Once a job is submitted to Rev AI for asynchronous transcription via the API, there are three ways to know when transcription is complete. You can check job status via the Rev AI Web dashboard; you can repeatedly poll the API for status; or you can let Rev AI notify you automatically via a webhook. Of these three options, using a webhook is the most efficient approach.

NOTE: If you're not familiar with webhooks, refer to our tutorial on [getting started with Rev AI webhooks](https://docs.rev.ai/resources/tutorials/get-started-api-webhooks/) for more information. You can also learn how to use Rev AI webhooks to [automatically send email notifications when a job completes](https://docs.rev.ai/resources/tutorials/send-email-notifications-webhooks/), or how to [retrieve final transcripts via the API and save them to a MongoDB database](https://docs.rev.ai/resources/tutorials/save-transcripts-mongodb-webhooks/).

To let Rev AI know that it should use a webhook, including the `notification_config` parameter in the request to the Asynchronous Speech-to-Text API endpoint at `https://api.rev.ai/speechtotext/v1/jobs`, as shown below:

```bash
curl -X POST "https://api.rev.ai/speechtotext/v1/jobs" \
     -H "Authorization: Bearer <REVAI_ACCESS_TOKEN>" \
     -H "Content-Type: application/json" \
     -d '{
       "source_config": {"url": "https://www.rev.ai/FTC_Sample_1.mp3"},
       "notification_config": {"url": "https://example.com/my/webhook"}
      }'
```

Once the job is complete, the API will make an HTTP POST request to the specified URL with a JSON document in the POST request body. Here is an example of the POST request body:

```json
{
  "job": {
    "id": "8xBckmk6rAqu",
    "created_on": "2022-08-16T14:26:14.151Z",
    "completed_on": "2022-08-16T14:26:53.601Z",
    "name": "FTC_Sample_1.mp3",
    "notification_config": {"url": "https://example.com/my/webhook"},
    "source_config": {"url": "https://www.rev.ai/FTC_Sample_1.mp3"},
    "status": "transcribed",
    "duration_seconds": 107,
    "type": "async",
    "language": "en"
  }
}
```

From the above discussion, it should be clear that there are two steps involved when integrating a Rev AI webhook into an application:

1. At job submission time, include a webhook URL in the `notification_config` parameter of the job request.
2. Define a webhook URL handler that is able to receive the HTTP POST request, parse the request body and take further action (such as retrieving the transcript) based on the received data.

NOTE: In order to receive notifications from Rev AI, the webhook URL must be a publicly-accessible URL. When developing and testing locally, this may not always be possible. For such situations, use [`ngrok`](https://ngrok.com) to create a temporary public URL that will serve as the webhook URL.

Make the changes as described below:

1. Update the `config/settings.php` file with an additional configuration key for the webhook URL.

    ```php
    <?php
    return [
        'rev' => [
            'token' => '<REVAI_ACCESS_TOKEN>',
            'callback' => '<CALLBACK_PREFIX>/hook',
        ],
        'mongo' => [
            'uri' => '<MONGODB_URI>'
        ]
    ];
    ```

    If you are deploying the application at an existing public URL, replace the `<CALLBACK_PREFIX>` placeholder with the application URL.

    If you are developing and testing locally without a publicly-accessible URL, first create and obtain a temporary webhook URL with `ngrok` and then replace the `<CALLBACK_PREFIX>` placeholder in the `config/settings.php` file with that temporary URL.

2. Update the front controller at `public\index.php` with the following changes:

  * Update the POST route handler for the `/add` URL endpoint to include the `notification_config` parameter in the job request.


      ```php
      <?php

      // ...

      // POST request handler for /add page
      $app->post(
          '/add',
          function (Request $request, Response $response) {
              // get MongoDB service
              // insert a record in the database for the audio upload
              // get MongoDB document ID
              $mongoClient = $this->get('mongo');
              try {
                  $insertResult = $mongoClient->mydb->notes->insertOne(
                      [
                          'status' => 'JOB_RECORDED',
                          'ts'     => time(),
                          'jid'    => false,
                          'error'  => false,
                          'data'   => false,
                      ]
                  );
                  $id           = (string) $insertResult->getInsertedId();
                  // get uploaded file
                  // if no upload errors, change status in database record
                  $uploadedFiles = $request->getUploadedFiles();
                  $uploadedFile = $uploadedFiles['file'];
                  if ($uploadedFile->getError() === UPLOAD_ERR_OK) {
                      $mongoClient->mydb->notes->updateOne(
                          [
                              '_id' => new ObjectID($id),
                          ],
                          [
                              '$set' => ['status' => 'JOB_UPLOADED'],
                          ]
                      );
                      // get Rev AI API client
                      // submit audio to API as POST request
                      $revClient   = $this->get('guzzle');
                      $revResponse = $revClient->request(
                          'POST',
                          'jobs',
                          [
                              'multipart' => [
                                  [
                                      'name'     => 'media',
                                      'contents' => fopen($uploadedFile->getFilePath(), 'r'),
                                  ],
                                  [
                                      'name'     => 'options',
                                      'contents' => json_encode(
                                          [
                                              'metadata'         => $id,
                                              'skip_diarization' => 'true',
                                              'notification_config'     => [
                                                  'url' => $this->get('settings')['rev']['callback']
                                              ],
                                          ]
                                      ),
                                  ],
                              ],
                          ]
                      )->getBody()->getContents();
                      // get API response
                      // if no API error, update status in database record
                      // send 200 response code to client
                      $json        = json_decode($revResponse);
                      $mongoClient->mydb->notes->updateOne(
                          [
                              '_id' => new ObjectID($id),
                          ],
                          [
                              '$set' => [
                                  'status' => 'JOB_TRANSCRIPTION_IN_PROGRESS',
                                  'jid'    => $json->id,
                              ],
                          ]
                      );
                      $response->getBody()->write(json_encode(['success' => true]));
                      return $response->withHeader('Content-Type', 'application/json')->withStatus(200);
                  }
              } catch (\GuzzleHttp\Exception\RequestException $e) {
                  // in case of API error
                  // update status in database record
                  // send error code to client with error message as payload
                  $mongoClient->mydb->notes->updateOne(
                      [
                          '_id' => new ObjectID($id),
                      ],
                      [
                          '$set' => [
                              'status' => 'JOB_TRANSCRIPTION_FAILURE',
                              'error'  => $e->getMessage(),
                          ],
                      ]
                  );
                  $response->getBody()->write(json_encode(['success' => false]));
                  return $response->withHeader('Content-Type', 'application/json')->withStatus($e->getResponse()->getStatusCode());
              }
          }
      );

      // ...
      ```

  * Add a new handler for POST requests to the `/hook` webhook URL endpoint, which accepts and processes the POST request received from the Rev AI API.

      ```php
      <?php

      // ...

      // POST request handler for /hook webhook URL
      $app->post(
          '/hook',
          function (Request $request, Response $response) {
              try {
                  // get MongoDB service
                  $mongoClient = $this->get('mongo');

                  // decode JSON request body
                  // obtain identifiers and status
                  $json        = json_decode($request->getBody());
                  $jid         = $json->job->id;
                  $id          = $json->job->metadata;

                  // if job successful
                  if ($json->job->status === 'transcribed') {

                      // update status in database
                      $mongoClient->mydb->notes->updateOne(
                          [
                              '_id' => new ObjectID($id),
                          ],
                          [
                              '$set' => ['status' => 'JOB_TRANSCRIPTION_SUCCESS'],
                          ]
                      );

                      // get transcript from API
                      $revClient   = $this->get('guzzle');
                      $revResponse = $revClient->request(
                          'GET',
                          "jobs/$jid/transcript",
                          [
                              'headers' => ['Accept' => 'text/plain'],
                          ]
                      )->getBody()->getContents();
                      $transcript  = explode('    ', $revResponse)[2];

                      // save transcript to database
                      $mongoClient->mydb->notes->updateOne(
                          [
                              '_id' => new ObjectID($id),
                          ],
                          [
                              '$set' => ['data' => $transcript],
                          ]
                      );
                  // if job unsuccesful
                  } else {

                      // update status in database
                      // save problem detail error message
                      $mongoClient->mydb->notes->updateOne(
                          [
                              '_id' => new ObjectID($id),
                          ],
                          [
                              '$set' => [
                                  'status' => 'JOB_TRANSCRIPTION_FAILURE',
                                  'error'  => $json->job->failure_detail,
                              ],
                          ]
                      );
                  }
              } catch (\GuzzleHttp\Exception\RequestException $e) {
                  $mongoClient->mydb->notes->updateOne(
                      [
                        '_id' => new ObjectID($id),
                      ],
                      [
                        '$set' => [
                            'status' => 'JOB_TRANSCRIPTION_FAILURE',
                            'error'  => $e->getMessage(),
                        ],
                      ]
                  );
              }
              return $response->withStatus(200);
          }
      );

      // ...
      ```

This route handler contains a lot of code, so let's step through it:

When this endpoint is invoked with a HTTP POST request, the handler first inspects the JSON document received and extracts three key pieces of information from it:
- The Rev AI job identifier, which will be used to retrieve the final transcript;
- The MongoDB document identifier, which serves to identify the corresponding record in the application database. Recollect that this MongoDB document identifier was included in the `metadata` parameter when the job was originally submitted;
- The Rev AI job status, which indicates whether transcription succeeded or failed.

If the job was successful, the handler updates the document `status` to `JOB_TRANSCRIPTION_SUCCESS`. It then uses the Guzzle Rev AI API client to prepare and send a HTTP GET request to `https://api.rev.ai/speechtotext/v1/jobs/<ID>/transcript` to retrieve the final transcript in plaintext format. The MongoDB document is then updated, with the transcript content saved to the document's `data` field.

If the job was unsuccessful, the handler updates the document `status` to `JOB_TRANSCRIPTION_FAILURE`. In this case, the JSON document also includes a problem description, which is saved to the document's `error` field in the MongoDB database.

On completion of the above actions, the handler returns a `200` response code to the Rev AI API server.

## Step 2: List transcripts in the application

Once the webhook is in place, retrieving and saving transcripts to the MongoDB database, the next step is to display them in the application user interface.

1. Update the GET route handler for the `/index` endpoint to execute a MongoDB query and return all the records in the application database. Make this change in the front controller at `public/index.php`.

    ```php
    <?php
    // ...

    // GET request handler for index page
    $app->get(
        '/[index[/]]',
        function (Request $request, Response $response, $args) {
            $params = $request->getQueryParams();
            $mongoClient = $this->get('mongo');
            return $this->get('view')->render(
                $response,
                'index.twig',
                [
                    'status' => !empty($params['status']) ? $params['status'] : null,
                    'data'   => $mongoClient->mydb->notes->find(
                        [],
                        [
                            'sort' => [
                                'ts' => -1,
                            ],
                        ]
                    )
                ]
            );
        }
    )->setName('index');

    // ...
    ```

    The MongoDB result set is returned via the `data` Twig template variable.

1. Update the `public/index.twig` page template to loop over the result set and display the MongoDB result set as an HTML table, including transcript content, timestamp, status and Rev AI job identifier. Add a "Refresh" button to the page as part of this update, which provides a way for the user to reload the page.

    ```html
    {% extends "layout.twig" %}

    {% block content %}
      <header class="d-flex justify-content-center py-3">
        <h1>My Notes</h1>
      </header>

      {% if status == 'submitted' %}
      <div class="alert alert-success text-center" role="alert">Audio sent for transcription.</div>
      {% endif %}

      {% if status == 'error' %}
      <div class="alert alert-danger text-center" role="alert">Audio transcription failed.</div>
      {% endif %}

      <table class="table">
        <thead class="thead-light">
          <tr>
            <th scope="col">#</th>
            <th scope="col">Date</th>
            <th scope="col">Contents</th>
            <th scope="col">Status</th>
            <th scope="col">Rev AI Job ID</th>
            <th scope="col"><a class="btn btn-primary" href="{{ url_for('index') }}" role="button">Refresh</a></th>
          </tr>
        </thead>
        <tbody>
      {% for item in data %}
        <tr>
          <td>{{ loop.index }}</td>
          <td>{{ item.ts|date("d M Y h:i e") }}</td>
          <td class="text-wrap" style="max-width: 150px;">{{ item.data }}</td>
        {% if item.status in ['JOB_UPLOADED', 'JOB_TRANSCRIPTION_IN_PROGRESS'] %}
          <td>In progress</td>
        {% elseif item.status == 'JOB_RECORDED' %}
          <td>Recorded</td>
        {% elseif item.status == 'JOB_TRANSCRIPTION_SUCCESS' %}
          <td>Transcribed</td>
        {% elseif item.status == 'JOB_TRANSCRIPTION_FAILURE' %}
          <td class="text-wrap" style="max-width: 150px;">Failed <br/> {{ item.error }}</td>
        {% endif %}
          <td>{{ item.jid }}</td>
          <td></td>
        </tr>
      {% endfor %}
        </tbody>
      </table>
    {% endblock %}
    ```

    This template loops over the `data` template variable and, for each record, displays the formatted date and time, transcript data, job status and Rev AI job identifier. Note that the template inspects the document's `status` field, which could be any one of `JOB_RECORDED`, `JOB_UPLOADED`, `JOB_TRANSCRIPTION_IN_PROGRESS`, `JOB_TRANSCRIPTION_SUCCESS` or `JOB_TRANSCRIPTION_FAILURE`, and displays a human-readable message for each, including an error message for failed jobs.

## Step 3: Search for transcripts in the application

Now that you have the basics in place, it's time to add further functionality to the application. For example, users should be able to quickly search the generated transcripts and find those matching specific terms.

This can be achieved by using a MongoDB regular expression to filter the stored transcripts and return the matching ones, as described below:

1. Update the index page template at `views/index.twig` to include a simple search form, as below:

    ```html
    {% extends "layout.twig" %}

    {% block content %}

      <!-- ... -->

      <div class="col-sm-4">
        <form class="form-inline">
          <div class="input-group">
            <input class="form-control" name="term" value="{{ term }}" placeholder="Search">
            <div class="input-group-append">
              <button class="btn btn-outline-secondary" type="submit">Go</button>
            </div>
          </div>
        </form>
      </div>

      <!-- ... -->

    {% endblock %}
    ```

    When this search form is submitted, the search term entered by the user is added to the URL as a query parameter.

1. Update the GET route handler for the `/index` endpoint in the front controller script `public/index.php` to read the submitted search term and modify the default MongoDB query with an additional regular expression filter. This change ensures that only results matching the search term are returned and displayed in the index page template.

    ```php
    <?php
    // ...

    // GET request handler for index page
    $app->get(
        '/[index[/]]',
        function (Request $request, Response $response, $args) {
            $params = $request->getQueryParams();
            $condition = !empty($params['term']) ?
                [
                    'data' => new MongoDB\BSON\Regex(filter_var($params['term'], FILTER_UNSAFE_RAW), 'i')
                ] :
                [];
            $mongoClient = $this->get('mongo');
            return $this->get('view')->render(
                $response,
                'index.twig',
                [
                    'status' => !empty($params['status']) ? $params['status'] : null,
                    'data'   => $mongoClient->mydb->notes->find(
                        $condition,
                        [
                            'sort' => [
                                'ts' => -1,
                            ],
                        ]
                    ),
                    'term' => !empty($params['term']) ? $params['term'] : null,
                ]
            );
        }
    )->setName('index');

    // ...
    ```

    The route handler uses the request object's `getQueryParams()` method to retrieve the search term and then adds a regular expression condition to the default MongoDB search query. This condition ensures that only transcripts containing the regular expression are returned by the query. The result set is then interpolated into the index page template for display as usual.

## Step 4: Delete transcripts from the application

Users should also have the option to delete voice notes which are no longer relevant through the Web application interface.

Implement this functionality as below:

1. Update the index page template at `views/index.twig` to support an additional "Delete" button for each displayed transcript and a new `deleted` status message, as below.

    ```html
    {% extends "layout.twig" %}

    {% block content %}

      <!-- ... -->

      {% if status == 'deleted' %}
      <div class="alert alert-success text-center" role="alert">Note deleted.</div>
      {% endif %}

      <!-- ... -->

      <table class="table">

        <!-- ... -->

        <tbody>
      {% for item in data %}
        <tr>
          <td>{{ loop.index }}</td>
          <td>{{ item.ts|date("d M Y h:i e") }}</td>
          <td class="text-wrap" style="max-width: 150px;">{{ item.data }}</td>
        {% if item.status in ['JOB_UPLOADED', 'JOB_TRANSCRIPTION_IN_PROGRESS'] %}
          <td>In progress</td>
        {% elseif item.status == 'JOB_RECORDED' %}
          <td>Recorded</td>
        {% elseif item.status == 'JOB_TRANSCRIPTION_SUCCESS' %}
          <td>Transcribed</td>
        {% elseif item.status == 'JOB_TRANSCRIPTION_FAILURE' %}
          <td class="text-wrap" style="max-width: 150px;">Failed <br/> {{ item.error }}</td>
        {% endif %}
          <td>{{ item.jid }}</td>
          <td><a class="btn btn-danger" href="{{ url_for('delete', { 'id': item._id }) }}" role="button">Delete</a></td>
        </tr>
      {% endfor %}
        </tbody>
      </table>
    {% endblock %}
    ```

    Note that the generated hyperlink for each "Delete" button will point to a route named `delete` and will include the MongoDB document identifier as a URL parameter.

1. Create a GET route handler for the new `/delete` endpoint which accepts a MongoDB document identifier as a URL parameter. Add this route to the front controller script `public/index.php`.

    ```php
    <?php

    // ...

    // GET request handler for /delete page
    $app->get(
        '/delete/{id}',
        function (Request $request, Response $response, $args) use ($app) {
            $id          = filter_var($args['id'], FILTER_UNSAFE_RAW);
            $mongoClient = $this->get('mongo');
            $mongoClient->mydb->notes->deleteOne(
                [
                    '_id' => new ObjectID($id),
                ]
            );
            $routeParser = $app->getRouteCollector()->getRouteParser();
            return $response->withHeader('Location', $routeParser->urlFor('index', [], ['status' => 'deleted']))->withStatus(302);
        }
    )->setName('delete');

    // ...
    ```

    This route handler reads the MongoDB document identifier, uses the MongoDB client's `deleteOne` method to delete the corresponding transcript from the application database and then redirects the user back to the `/index` URL with a success notification.

NOTE: Deleting a voice note as described in this section removes it from the application database, but does not delete it from Rev AI's servers. To do this, you must either submit a separate `DELETE` request via the API or include the `delete_after_seconds` parameter in the initial job request. To learn more, refer to the tutorial on deleting user data from Rev AI.

For reference, here is the final front controller script. Replace the `public\index.php` file with this version.

```php
<?php

use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Factory\AppFactory;
use Slim\Views\Twig;
use Slim\Views\TwigMiddleware;
use Slim\Routing\RouteContext;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7;
use DI\ContainerBuilder;
use MongoDB\BSON\ObjectID;

// load dependencies
require __DIR__ . '/https://docs.rev.ai/resources/tutorials/vendor/autoload.php';

// create DI container
$containerBuilder = new ContainerBuilder();

// define services
$containerBuilder->addDefinitions(
    [
        'settings' => function () {
            return include __DIR__ . '/https://docs.rev.ai/resources/tutorials/config/settings.php';
        },
        'view'     => function () {
            return Twig::create(__DIR__ . '/https://docs.rev.ai/resources/tutorials/views');
        },
        'mongo'    => function ($c) {
            return new MongoDB\Client($c->get('settings')['mongo']['uri']);
        },
        'guzzle'   => function ($c) {
            $token = $c->get('settings')['rev']['token'];
            return new Client(
                [
                    'base_uri' => 'https://api.rev.ai/speechtotext/v1/jobs',
                    'headers'  => ['Authorization' => "Bearer $token"],
                ]
            );
        },
    ]
);

$container = $containerBuilder->build();

AppFactory::setContainer($container);

// create application with DI container
$app = AppFactory::create();

// add Twig middleware
$app->add(TwigMiddleware::createFromContainer($app));

// add error handling middleware
$app->addErrorMiddleware(true, true, true);

// GET request handler for index page
$app->get(
  '/[index[/]]',
  function (Request $request, Response $response, $args) {
      $params = $request->getQueryParams();
      $condition = !empty($params['term']) ?
          [
              'data' => new MongoDB\BSON\Regex(filter_var($params['term'], FILTER_UNSAFE_RAW), 'i')
          ] :
          [];
      $mongoClient = $this->get('mongo');
      return $this->get('view')->render(
          $response,
          'index.twig',
          [
              'status' => !empty($params['status']) ? $params['status'] : null,
              'data'   => $mongoClient->mydb->notes->find(
                  $condition,
                  [
                      'sort' => [
                          'ts' => -1,
                      ],
                  ]
              ),
              'term' => !empty($params['term']) ? $params['term'] : null,
          ]
      );
  }
)->setName('index');

// GET request handler for /add page
$app->get(
    '/add',
    function (Request $request, Response $response, $args) {
        return $this->get('view')->render(
            $response,
            'add.twig',
            []
        );
    }
)->setName('add');

// POST request handler for /add page
$app->post(
    '/add',
    function (Request $request, Response $response) {
        // get MongoDB service
        // insert a record in the database for the audio upload
        // get MongoDB document ID
        $mongoClient = $this->get('mongo');
        try {
            $insertResult = $mongoClient->mydb->notes->insertOne(
                [
                    'status' => 'JOB_RECORDED',
                    'ts'     => time(),
                    'jid'    => false,
                    'error'  => false,
                    'data'   => false,
                ]
            );
            $id           = (string) $insertResult->getInsertedId();

            // get uploaded file
            // if no upload errors, change status in database record
            $uploadedFiles = $request->getUploadedFiles();
            $uploadedFile = $uploadedFiles['file'];

            if ($uploadedFile->getError() === UPLOAD_ERR_OK) {
                $mongoClient->mydb->notes->updateOne(
                    [
                        '_id' => new ObjectID($id),
                    ],
                    [
                        '$set' => ['status' => 'JOB_UPLOADED'],
                    ]
                );

                // get Rev AI API client
                // submit audio to API as POST request
                $revClient   = $this->get('guzzle');
                $revResponse = $revClient->request(
                    'POST',
                    'jobs',
                    [
                        'multipart' => [
                            [
                                'name'     => 'media',
                                'contents' => fopen($uploadedFile->getFilePath(), 'r'),
                            ],
                            [
                                'name'     => 'options',
                                'contents' => json_encode(
                                    [
                                        'metadata'         => $id,
                                        'notification_config'     => [
                                            'url' => $this->get('settings')['rev']['callback']
                                        ],
                                        'skip_diarization' => 'true',
                                    ]
                                ),
                            ],
                        ],
                    ]
                )->getBody()->getContents();

                // get API response
                // if no API error, update status in database record
                // send 200 response code to client
                $json        = json_decode($revResponse);
                $mongoClient->mydb->notes->updateOne(
                    [
                        '_id' => new ObjectID($id),
                    ],
                    [
                        '$set' => [
                            'status' => 'JOB_TRANSCRIPTION_IN_PROGRESS',
                            'jid'    => $json->id,
                        ],
                    ]
                );
                $response->getBody()->write(json_encode(['success' => true]));
                return $response->withHeader('Content-Type', 'application/json')->withStatus(200);
            }
        } catch (\GuzzleHttp\Exception\RequestException $e) {
            // in case of API error
            // update status in database record
            // send error code to client with error message as payload
            $mongoClient->mydb->notes->updateOne(
                [
                    '_id' => new ObjectID($id),
                ],
                [
                    '$set' => [
                        'status' => 'JOB_TRANSCRIPTION_FAILURE',
                        'error'  => $e->getMessage(),
                    ],
                ]
            );
            $response->getBody()->write(json_encode(['success' => false]));
            return $response->withHeader('Content-Type', 'application/json')->withStatus($e->getResponse()->getStatusCode());
        }
    }
);

// GET request handler for /delete page
$app->get(
    '/delete/{id}',
    function (Request $request, Response $response, $args) use ($app) {
        $id          = filter_var($args['id'], FILTER_UNSAFE_RAW);
        $mongoClient = $this->get('mongo');
        $mongoClient->mydb->notes->deleteOne(
            [
                '_id' => new ObjectID($id),
            ]
        );
        $routeParser = $app->getRouteCollector()->getRouteParser();
        return $response->withHeader('Location', $routeParser->urlFor('index', [], ['status' => 'deleted']))->withStatus(302);
    }
)->setName('delete');

// POST request handler for /hook webhook URL
$app->post(
    '/hook',
    function (Request $request, Response $response) {
        try {
            // get MongoDB service
            $mongoClient = $this->get('mongo');

            // decode JSON request body
            // obtain identifiers and status
            $json        = json_decode($request->getBody());
            $jid         = $json->job->id;
            $id          = $json->job->metadata;

            // if job successful
            if ($json->job->status === 'transcribed') {

                // update status in database
                $mongoClient->mydb->notes->updateOne(
                    [
                        '_id' => new ObjectID($id),
                    ],
                    [
                        '$set' => ['status' => 'JOB_TRANSCRIPTION_SUCCESS'],
                    ]
                );

                // get transcript from API
                $revClient   = $this->get('guzzle');
                $revResponse = $revClient->request(
                    'GET',
                    "jobs/$jid/transcript",
                    [
                        'headers' => ['Accept' => 'text/plain'],
                    ]
                )->getBody()->getContents();
                $transcript  = explode('    ', $revResponse)[2];

                // save transcript to database
                $mongoClient->mydb->notes->updateOne(
                    [
                        '_id' => new ObjectID($id),
                    ],
                    [
                        '$set' => ['data' => $transcript],
                    ]
                );
            // if job unsuccesful
            } else {

                // update status in database
                // save problem detail error message
                $mongoClient->mydb->notes->updateOne(
                    [
                        '_id' => new ObjectID($id),
                    ],
                    [
                        '$set' => [
                            'status' => 'JOB_TRANSCRIPTION_FAILURE',
                            'error'  => $json->job->failure_detail,
                        ],
                    ]
                );
            }
        } catch (\GuzzleHttp\Exception\RequestException $e) {
            $mongoClient->mydb->notes->updateOne(
                [
                  '_id' => new ObjectID($id),
                ],
                [
                  '$set' => [
                      'status' => 'JOB_TRANSCRIPTION_FAILURE',
                      'error'  => $e->getMessage(),
                  ],
                ]
            );
        }
        return $response->withStatus(200);
    }
);

$app->run();

```

## Step 5: Test the example application

Test the example application by browsing to `http://<DOCKER_HOST>`. You should see the page below.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3q89e8zuoaohyk1bx23y.png)

Click the "Add" button in the top right corner. You will be redirected to a new page and the browser will prompt for access to the system microphone. Grant this permission, then click the "Start recording" button. Speak and click "Stop recording" once done. Your audio will be uploaded and you should be redirected back to the index page, where you should see your voice note listed with status "In progress".


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nyyzzxcmnck6fnn3mie8.png)

When the transcript is ready, Rev AI will notify the webhook URL. If you are using a public URL, you will be able to see this activity in the Web server logs. Alternatively, if you are using `ngrok`, you will be able to see this activity in the `ngrok` dashboard, as shown below:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vvkybpepe0gzfghig6ma.png)

When you see the incoming webhook request in the Web server logs or `ngrok` monitor, or after approximately 1 minute if you do not have access to monitoring, click the "Refresh" button. The  list of voice notes should now reflect the updated status, together with the transcript, as shown below:


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/19a3g39dj3k8dbawpne9.png)

In case the transcript could not be generated due to an error, this too should be visible in the list:


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/c35wu80fyr7msh0gclh6.png)

Enter a search term in the search box and click the "Go" button. The list of voice notes should now be filtered to only display those containing your search term, as shown below:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/22a38c8dbkg1lp7ppncy.png)

Click the "Delete" button next to a voice note. The note will be deleted from the database and will vanish from the list of available notes.

## Next steps

In this concluding segment, you learnt how to retrieve the final transcript from Rev AI using a webhook and display it in the Web application. You also improved the Web application with basic search and deletion features.

With this, the example speech-to-text Web application is complete. Audio is received and recorded through the browser and transcripts are generated, returned and integrated into the application with Rev AI and PHP.

Learn more about developing speech-to-text applications with Rev AI and PHP by visiting the following links:

- Documentation: Asynchronous Speech-To-Text API [job submission](https://docs.rev.ai/api/asynchronous/reference#operation/SubmitTranscriptionJob!ct=application/json&path=callback_url&t=request), [transcript retrieval](https://docs.rev.ai/api/asynchronous/reference#operation/GetTranscriptById) and [job deletion](https://docs.rev.ai/api/asynchronous/reference#operation/DeleteJobById)
- Code samples: [Asynchronous Speech-To-Text API](https://docs.rev.ai/api/asynchronous/code-samples.md)
- Tutorial: [Get Started with Rev AI API Webhooks](https://docs.rev.ai/resources/tutorials/get-started-api-webhooks/)
- Tutorial: [Use Webhooks to Trigger Job Email Notifications with Node.js, SendGrid and Express](https://docs.rev.ai/resources/tutorials/send-email-notifications-webhooks/)
- Tutorial: [Save Transcripts to MongoDB with a Node.js Webhook](https://docs.rev.ai/resources/tutorials/save-transcripts-mongodb-webhooks/)
- Tutorial: [Delete User Files from Rev AI](https://docs.rev.ai/resources/tutorials/delete-user-files/)
- Tutorial: [Asynchronous Speech-To-Text API best practices](https://docs.rev.ai/api/asynchronous/best-practices.md)
- Documentation: [Slim framework](https://www.slimframework.com/docs/v4/)
- Documentation: [Guzzle PHP HTTP client](https://docs.guzzlephp.org/en/stable/)
- Documentation: [MongoDB PHP driver](https://www.mongodb.com/docs/drivers/php/)