# Build an order-tracking line

In this tutorial, you build a phone line for a delivery company that lets callers check on an order by entering a tracking number. The line greets the caller, collects a ten-digit tracking number with [`<Gather>`](/voice/oneml/gather), reads back the order's status and estimated delivery with [`<Say>`](/voice/oneml/say), lets the caller repeat the details or ask for an agent, and connects to a live agent with [`<Dial>`](/voice/oneml/dial) when requested. By the end, you have a complete server, in Node.js or Python, that handles every step of the call across four routes:

- `/voice`: greets the caller and collects a tracking number.
- `/track`: looks up the tracking number and reports its status, or apologizes and starts over when it does not recognize the number.
- `/next`: repeats the status or connects the caller to an agent, based on what the caller presses next.
- `/agent-result`: reports back once the agent call ends, in case no agent answers.

**Time to complete:** 30 minutes

## Prerequisites

- Node.js 18 or later, or Python 3.9 or later, depending on which language you follow.
- A phone number on your account with a configurable voice URL. See [Receiving calls](/voice/receiving-calls) if you do not yet have one.
- A tunneling tool that exposes a local port as a public HTTPS URL, used in the last step to let PressOne reach your local server.

Familiarity with [Gather](/voice/oneml/gather), [Dial](/voice/oneml/dial), and [Redirect](/voice/oneml/redirect) helps but is not required. This tutorial links to the relevant reference section at each step.

## Step 1: Scaffold the server

Start with the application shell and the order data the rest of the tutorial reads from. `orders` stands in for a database lookup, keyed by a ten-digit tracking number, with a `status` and an `eta` field for each order. `attempts` is a second in-memory store, keyed by `CallSid`, that later steps use to count how many times a caller has entered a tracking number that does not match anything in `orders`.

Save the following as `server.js` (Node.js) or `app.py` (Python):

<CodeTabs syncKey="lang">

```javascript title="Node.js"
const express = require("express");
const app = express();
app.use(express.urlencoded({ extended: false }));

const orders = {
  "4825601937": { status: "out for delivery", eta: "today by 6:00 PM" },
  "7734105982": { status: "in transit", eta: "tomorrow by 8:00 PM" },
  "1948273650": { status: "delivered", eta: "yesterday at 2:15 PM" },
};

const attempts = {};

app.listen(3000);
```

```python title="Python"
from flask import Flask, request, Response

app = Flask(__name__)

orders = {
    "4825601937": {"status": "out for delivery", "eta": "today by 6:00 PM"},
    "7734105982": {"status": "in transit", "eta": "tomorrow by 8:00 PM"},
    "1948273650": {"status": "delivered", "eta": "yesterday at 2:15 PM"},
}

attempts = {}

if __name__ == "__main__":
    app.run(port=3000)
```

</CodeTabs>

This server runs but has no routes yet. Each of the following steps adds one route and shows the file again with that route in place.

## Step 2: Greet the caller and collect a tracking number

`/voice` is the route you eventually put on the number's voice URL. It greets the caller and collects exactly ten digits with `<Gather>`, then sends them to `/track`:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Gather action="/track" numDigits="10" timeout="10">
    <Say>Welcome to Acme Delivery. Enter your ten digit tracking number.</Say>
  </Gather>
  <Say>We did not receive a tracking number.</Say>
  <Redirect>/voice</Redirect>
</Response>
```

With `numDigits="10"`, gathering ends the instant the caller enters the tenth digit: the caller does not need to press `#` afterward. If the caller enters nothing before the `timeout` elapses, gathering ends with no result. This `<Gather>` leaves `actionOnEmptyResult` at its default of `false`, so PressOne does not request `/track` in that case at all: execution falls through directly to the next verb in this same document, the fallback `<Say>` and `<Redirect>` back to `/voice`. Setting `actionOnEmptyResult="true"` would instead request `/track` with `Digits` absent from the request, which is useful when the lookup handler itself needs to react to silence, but is unnecessary here since the retry lives in this same document.

Add the route to the server from Step 1:

<CodeTabs syncKey="lang">

```javascript title="Node.js"
const express = require("express");
const app = express();
app.use(express.urlencoded({ extended: false }));

const orders = {
  "4825601937": { status: "out for delivery", eta: "today by 6:00 PM" },
  "7734105982": { status: "in transit", eta: "tomorrow by 8:00 PM" },
  "1948273650": { status: "delivered", eta: "yesterday at 2:15 PM" },
};

const attempts = {};

app.post("/voice", (req, res) => {
  const oneml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Gather action="/track" numDigits="10" timeout="10">
    <Say>Welcome to Acme Delivery. Enter your ten digit tracking number.</Say>
  </Gather>
  <Say>We did not receive a tracking number.</Say>
  <Redirect>/voice</Redirect>
</Response>`;

  res.type("text/xml").send(oneml);
});

app.listen(3000);
```

```python title="Python"
from flask import Flask, request, Response

app = Flask(__name__)

orders = {
    "4825601937": {"status": "out for delivery", "eta": "today by 6:00 PM"},
    "7734105982": {"status": "in transit", "eta": "tomorrow by 8:00 PM"},
    "1948273650": {"status": "delivered", "eta": "yesterday at 2:15 PM"},
}

attempts = {}

@app.route("/voice", methods=["POST"])
def voice():
    oneml = """<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Gather action="/track" numDigits="10" timeout="10">
    <Say>Welcome to Acme Delivery. Enter your ten digit tracking number.</Say>
  </Gather>
  <Say>We did not receive a tracking number.</Say>
  <Redirect>/voice</Redirect>
</Response>"""

    return Response(oneml, mimetype="text/xml")

if __name__ == "__main__":
    app.run(port=3000)
```

</CodeTabs>

## Step 3: Look up the tracking number

`/track` reads the collected digits, looks the tracking number up in `orders`, and branches on whether it finds a match. On a match, it reads back the status and ETA, then opens a second, single-digit `<Gather>` that offers to repeat the details or connect to an agent:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>Your order is out for delivery. Estimated delivery: today by 6:00 PM.</Say>
  <Gather action="/next?tn=4825601937" numDigits="1" timeout="10">
    <Say>To hear this again, press 1. To speak with an agent, press 2.</Say>
  </Gather>
  <Say>We did not receive a selection. Goodbye.</Say>
  <Hangup/>
</Response>
```

The `action` URL carries the tracking number as a query parameter (`/next?tn=4825601937`) because `/next` (built in the next step) needs to know which order the caller is asking about, and the digit it receives is the menu selection, not the tracking number. Action URLs can carry a query string like this one; PressOne requests the URL as given, query string included, and your route reads `tn` from the query string alongside whatever PressOne posts in the request body.

When the tracking number does not match an order, `/track` apologizes and redirects back to `/voice` so the caller can try again:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>We could not find an order with that tracking number. Let us try again.</Say>
  <Redirect>/voice</Redirect>
</Response>
```

OneML caps a document at 10 consecutive redirects that happen without new caller input, ending the call with an error past that limit. See [Redirect](/voice/oneml/redirect). Each retry here only happens after the caller enters another set of digits, and a redirect triggered by renewed caller input resets the counter, so repeated wrong guesses do not, on their own, run into that limit. Still, prompting the same caller for a tracking number indefinitely is a poor experience well before ten attempts. `/track` tracks how many times each call, keyed by `CallSid`, has failed to find a match in the `attempts` map from Step 1, and after three misses ends the call instead of asking a fourth time:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>We still could not find that tracking number. Please check it and call back. Goodbye.</Say>
  <Hangup/>
</Response>
```

Add the route:

<CodeTabs syncKey="lang">

```javascript title="Node.js"
const express = require("express");
const app = express();
app.use(express.urlencoded({ extended: false }));

const orders = {
  "4825601937": { status: "out for delivery", eta: "today by 6:00 PM" },
  "7734105982": { status: "in transit", eta: "tomorrow by 8:00 PM" },
  "1948273650": { status: "delivered", eta: "yesterday at 2:15 PM" },
};

const attempts = {};

app.post("/voice", (req, res) => {
  const oneml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Gather action="/track" numDigits="10" timeout="10">
    <Say>Welcome to Acme Delivery. Enter your ten digit tracking number.</Say>
  </Gather>
  <Say>We did not receive a tracking number.</Say>
  <Redirect>/voice</Redirect>
</Response>`;

  res.type("text/xml").send(oneml);
});

app.post("/track", (req, res) => {
  const trackingNumber = req.query.tn || req.body.Digits;
  const callSid = req.body.CallSid;
  const order = orders[trackingNumber];
  let oneml;

  if (order) {
    delete attempts[callSid];
    oneml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>Your order is ${order.status}. Estimated delivery: ${order.eta}.</Say>
  <Gather action="/next?tn=${trackingNumber}" numDigits="1" timeout="10">
    <Say>To hear this again, press 1. To speak with an agent, press 2.</Say>
  </Gather>
  <Say>We did not receive a selection. Goodbye.</Say>
  <Hangup/>
</Response>`;
  } else {
    attempts[callSid] = (attempts[callSid] || 0) + 1;

    if (attempts[callSid] < 3) {
      oneml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>We could not find an order with that tracking number. Let us try again.</Say>
  <Redirect>/voice</Redirect>
</Response>`;
    } else {
      delete attempts[callSid];
      oneml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>We still could not find that tracking number. Please check it and call back. Goodbye.</Say>
  <Hangup/>
</Response>`;
    }
  }

  res.type("text/xml").send(oneml);
});

app.listen(3000);
```

```python title="Python"
from flask import Flask, request, Response

app = Flask(__name__)

orders = {
    "4825601937": {"status": "out for delivery", "eta": "today by 6:00 PM"},
    "7734105982": {"status": "in transit", "eta": "tomorrow by 8:00 PM"},
    "1948273650": {"status": "delivered", "eta": "yesterday at 2:15 PM"},
}

attempts = {}

@app.route("/voice", methods=["POST"])
def voice():
    oneml = """<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Gather action="/track" numDigits="10" timeout="10">
    <Say>Welcome to Acme Delivery. Enter your ten digit tracking number.</Say>
  </Gather>
  <Say>We did not receive a tracking number.</Say>
  <Redirect>/voice</Redirect>
</Response>"""

    return Response(oneml, mimetype="text/xml")

@app.route("/track", methods=["POST"])
def track():
    tracking_number = request.args.get("tn") or request.form.get("Digits")
    call_sid = request.form.get("CallSid")
    order = orders.get(tracking_number)

    if order:
        attempts.pop(call_sid, None)
        oneml = f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>Your order is {order['status']}. Estimated delivery: {order['eta']}.</Say>
  <Gather action="/next?tn={tracking_number}" numDigits="1" timeout="10">
    <Say>To hear this again, press 1. To speak with an agent, press 2.</Say>
  </Gather>
  <Say>We did not receive a selection. Goodbye.</Say>
  <Hangup/>
</Response>"""
    else:
        attempts[call_sid] = attempts.get(call_sid, 0) + 1

        if attempts[call_sid] < 3:
            oneml = """<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>We could not find an order with that tracking number. Let us try again.</Say>
  <Redirect>/voice</Redirect>
</Response>"""
        else:
            attempts.pop(call_sid, None)
            oneml = """<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>We still could not find that tracking number. Please check it and call back. Goodbye.</Say>
  <Hangup/>
</Response>"""

    return Response(oneml, mimetype="text/xml")

if __name__ == "__main__":
    app.run(port=3000)
```

</CodeTabs>

## Step 4: Repeat the details or connect an agent

`/next` reads the single digit collected by the `<Gather>` in `/track`, along with the `tn` query parameter carried on its own `action` URL. Pressing `1` replays the order by redirecting back to `/track`, passing the tracking number through the same query-parameter pattern:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Redirect>/track?tn=4825601937</Redirect>
</Response>
```

`/track` already reads `tn` from the query string ahead of `Digits`, so this redirect reruns the same lookup and Says the same status and ETA again, without asking the caller to re-enter the tracking number.

Pressing `2` dials a support number and sets `action` to `/agent-result` so the server hears back once the dial ends:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>Connecting you to an agent.</Say>
  <Dial action="/agent-result" callerId="+2341700000000" timeout="20">
    <Number>+2341800000001</Number>
  </Dial>
</Response>
```

`/agent-result` reads `DialCallStatus` from that request. A value of `completed` means the call bridged and has already ended normally, so the handler hangs up without an announcement. Any other value (`busy`, `no-answer`, `canceled`, or `failed`) means no agent picked up, so the handler apologizes instead:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>Our agents are unavailable right now. Please call back later. Goodbye.</Say>
  <Hangup/>
</Response>
```

Add both routes. This is the complete, assembled server:

<CodeTabs syncKey="lang">

```javascript title="Node.js"
const express = require("express");
const app = express();
app.use(express.urlencoded({ extended: false }));

const orders = {
  "4825601937": { status: "out for delivery", eta: "today by 6:00 PM" },
  "7734105982": { status: "in transit", eta: "tomorrow by 8:00 PM" },
  "1948273650": { status: "delivered", eta: "yesterday at 2:15 PM" },
};

const attempts = {};

app.post("/voice", (req, res) => {
  const oneml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Gather action="/track" numDigits="10" timeout="10">
    <Say>Welcome to Acme Delivery. Enter your ten digit tracking number.</Say>
  </Gather>
  <Say>We did not receive a tracking number.</Say>
  <Redirect>/voice</Redirect>
</Response>`;

  res.type("text/xml").send(oneml);
});

app.post("/track", (req, res) => {
  const trackingNumber = req.query.tn || req.body.Digits;
  const callSid = req.body.CallSid;
  const order = orders[trackingNumber];
  let oneml;

  if (order) {
    delete attempts[callSid];
    oneml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>Your order is ${order.status}. Estimated delivery: ${order.eta}.</Say>
  <Gather action="/next?tn=${trackingNumber}" numDigits="1" timeout="10">
    <Say>To hear this again, press 1. To speak with an agent, press 2.</Say>
  </Gather>
  <Say>We did not receive a selection. Goodbye.</Say>
  <Hangup/>
</Response>`;
  } else {
    attempts[callSid] = (attempts[callSid] || 0) + 1;

    if (attempts[callSid] < 3) {
      oneml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>We could not find an order with that tracking number. Let us try again.</Say>
  <Redirect>/voice</Redirect>
</Response>`;
    } else {
      delete attempts[callSid];
      oneml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>We still could not find that tracking number. Please check it and call back. Goodbye.</Say>
  <Hangup/>
</Response>`;
    }
  }

  res.type("text/xml").send(oneml);
});

app.post("/next", (req, res) => {
  const digits = req.body.Digits;
  const trackingNumber = req.query.tn;
  let oneml;

  if (digits === "1") {
    oneml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Redirect>/track?tn=${trackingNumber}</Redirect>
</Response>`;
  } else if (digits === "2") {
    oneml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>Connecting you to an agent.</Say>
  <Dial action="/agent-result" callerId="+2341700000000" timeout="20">
    <Number>+2341800000001</Number>
  </Dial>
</Response>`;
  } else {
    oneml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>That is not a valid option. Goodbye.</Say>
  <Hangup/>
</Response>`;
  }

  res.type("text/xml").send(oneml);
});

app.post("/agent-result", (req, res) => {
  const status = req.body.DialCallStatus;
  let oneml;

  if (status === "completed") {
    oneml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Hangup/>
</Response>`;
  } else {
    oneml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>Our agents are unavailable right now. Please call back later. Goodbye.</Say>
  <Hangup/>
</Response>`;
  }

  res.type("text/xml").send(oneml);
});

app.listen(3000);
```

```python title="Python"
from flask import Flask, request, Response

app = Flask(__name__)

orders = {
    "4825601937": {"status": "out for delivery", "eta": "today by 6:00 PM"},
    "7734105982": {"status": "in transit", "eta": "tomorrow by 8:00 PM"},
    "1948273650": {"status": "delivered", "eta": "yesterday at 2:15 PM"},
}

attempts = {}

@app.route("/voice", methods=["POST"])
def voice():
    oneml = """<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Gather action="/track" numDigits="10" timeout="10">
    <Say>Welcome to Acme Delivery. Enter your ten digit tracking number.</Say>
  </Gather>
  <Say>We did not receive a tracking number.</Say>
  <Redirect>/voice</Redirect>
</Response>"""

    return Response(oneml, mimetype="text/xml")

@app.route("/track", methods=["POST"])
def track():
    tracking_number = request.args.get("tn") or request.form.get("Digits")
    call_sid = request.form.get("CallSid")
    order = orders.get(tracking_number)

    if order:
        attempts.pop(call_sid, None)
        oneml = f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>Your order is {order['status']}. Estimated delivery: {order['eta']}.</Say>
  <Gather action="/next?tn={tracking_number}" numDigits="1" timeout="10">
    <Say>To hear this again, press 1. To speak with an agent, press 2.</Say>
  </Gather>
  <Say>We did not receive a selection. Goodbye.</Say>
  <Hangup/>
</Response>"""
    else:
        attempts[call_sid] = attempts.get(call_sid, 0) + 1

        if attempts[call_sid] < 3:
            oneml = """<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>We could not find an order with that tracking number. Let us try again.</Say>
  <Redirect>/voice</Redirect>
</Response>"""
        else:
            attempts.pop(call_sid, None)
            oneml = """<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>We still could not find that tracking number. Please check it and call back. Goodbye.</Say>
  <Hangup/>
</Response>"""

    return Response(oneml, mimetype="text/xml")

@app.route("/next", methods=["POST"])
def next_step():
    digits = request.form.get("Digits")
    tracking_number = request.args.get("tn")

    if digits == "1":
        oneml = f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Redirect>/track?tn={tracking_number}</Redirect>
</Response>"""
    elif digits == "2":
        oneml = """<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>Connecting you to an agent.</Say>
  <Dial action="/agent-result" callerId="+2341700000000" timeout="20">
    <Number>+2341800000001</Number>
  </Dial>
</Response>"""
    else:
        oneml = """<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>That is not a valid option. Goodbye.</Say>
  <Hangup/>
</Response>"""

    return Response(oneml, mimetype="text/xml")

@app.route("/agent-result", methods=["POST"])
def agent_result():
    status = request.form.get("DialCallStatus")

    if status == "completed":
        oneml = """<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Hangup/>
</Response>"""
    else:
        oneml = """<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>Our agents are unavailable right now. Please call back later. Goodbye.</Say>
  <Hangup/>
</Response>"""

    return Response(oneml, mimetype="text/xml")

if __name__ == "__main__":
    app.run(port=3000)
```

</CodeTabs>

Every path the document reaches leads to a route defined above: `/voice` gathers into `/track`; `/track` gathers into `/next` or redirects back to `/voice`; `/next` redirects back to `/track` or dials into `/agent-result`; `/agent-result` always ends the call.

Replace `+2341700000000` and `+2341800000001` with a number your account owns and a real number you can answer, respectively, before testing the agent leg end to end. `+2341700000000` must belong to your account: PressOne fails the dial with `DialCallStatus` of `failed` otherwise, which routes to the same apology as `busy` or `no-answer`.

## Step 5: Test the line locally

Start the server:

<CodeTabs syncKey="lang">

```bash title="Node.js"
node server.js
```

```bash title="Python"
python app.py
```

</CodeTabs>

It listens on port 3000 for the four routes built above. Expose that port to the internet with a tunneling tool of your choice. Most such tools print a public HTTPS URL that forwards to your local server. Copy that URL for the next step.

In your dashboard, open the configuration for the number you are testing with, and set its voice URL to the public URL from your tunnel, followed by `/voice`, with `POST` as the method (for example `https://YOUR-TUNNEL-URL/voice`).

Call the number from any phone.

## Verify the result

You should hear: "Welcome to Acme Delivery. Enter your ten digit tracking number." Enter `4825601937`, one of the tracking numbers seeded in Step 1. PressOne reads back "Your order is out for delivery. Estimated delivery: today by 6:00 PM," followed by "To hear this again, press 1. To speak with an agent, press 2."

- Press `1`: PressOne repeats the same status and ETA.
- Press `2`: PressOne dials the number configured in `/next`, and either bridges the call or plays the fallback apology from `/agent-result`, depending on whether it answers.
- Enter a tracking number that is not in `orders` three times in a row: the first two attempts apologize and return you to the greeting; the third ends the call with a final apology instead of asking again.
- Enter nothing at the initial prompt and let it time out: PressOne says it did not receive a tracking number and returns you to the greeting.

## Troubleshooting

### PressOne never requests your server

Confirm the tunnel is still running and printing the same public URL configured on the number's voice URL, and that the voice URL's method is set to `POST` to match the routes above, which only handle `POST`. Check your tunneling tool's own request log. If it shows no incoming requests, the number's voice URL configuration is the more likely cause; if it shows requests but your server never logs them, check that the server is running on port 3000.

### The call ends abruptly with an error partway through

This usually means a route returned something that is not well-formed XML, or omitted the `text/xml` content type. Double-check that every branch calls `res.type("text/xml").send(oneml)` or returns `Response(oneml, mimetype="text/xml")`, and that any value interpolated into an `oneml` string (an order status, an ETA) does not itself contain characters like `<` or `&` that would break the XML.

### Pressing 2 does not connect a call

`/next` dials the placeholder numbers `+2341700000000` and `+2341800000001`. Replace both with a number your account owns and a real number you can answer, as noted in Step 4, before testing this branch.

### The caller reaches the final apology sooner than expected

`attempts` is an in-memory map that resets whenever the server restarts. If you restart the server between calls while testing the not-found path, a caller who previously had one or two failed attempts recorded starts over at zero on the next call: this is expected during local testing, but plan for a persistent store, such as your call database, if you carry this pattern into production.

## Next steps

- [Gather](/voice/oneml/gather): the complete attribute and action-parameter reference.
- [Dial](/voice/oneml/dial): the complete attribute and action-parameter reference.
- [Redirect](/voice/oneml/redirect): the redirect-loop limit and method options.
- [Forwarding calls](/voice/forwarding-calls): more on choosing a caller ID and ringing multiple numbers.
- [Webhooks](/voice/webhooks): verify that requests to routes like these genuinely came from PressOne before trusting them in production.
