60db Logo
  • Guides

DTMF plus voice for reliable identifier capture

DTMF plus voice needs one field owner. Run a reducer for repeated presses, stale speech, leading zeroes, and current confirmation.

Editorial date
Reading time
29 min
Voice and keypad feed one owned synthetic identifier before confirmation

TL;DR: Voice and DTMF should update one owned identifier field with explicit modality arbitration, correction, expiry, and confirmation rules. Preserve leading zeroes, coalesce repeated reports of one telephone event, retain genuinely repeated presses, and treat a captured identifier as data rather than authentication or permission to act.


A caller says an identifier while pressing a keypad digit. The speech recognizer returns a final result just as the first key arrives. Two callbacks update the same string, a confirmation uses whichever value it read first, and the application has no record of which input owned the field.

The repair starts with one field controller. The workflow enforcement guide explains why an action needs a current approved value. Multimodal capture adds another requirement: the value must come from the selected input owner under a declared arbitration rule.

This guide supplies a bounded standard-library reducer for a synthetic fixed-length ASCII identifier. It also includes a normalized telephone-event coalescer and negative fixtures. It does not implement a telephone stack, authenticate callers, certify accessibility, or claim measured carrier behavior.

Current Twilio Gather documentation gives the first detected input precedence when speech and DTMF are enabled together. That provider contract matters: a mixed-input request does not necessarily provide two independent concurrent streams that your application can merge however it likes.

Table of contents

  1. Define the field before choosing a modality
  2. Separate a key press from transport reports
  3. Choose an atomic ownership rule
  4. Run the bounded identifier reducer
  5. Confirm a current snapshot
  6. Design corrections and recovery
  7. Respect provider and accessibility boundaries
  8. Protect the business action
  9. Evaluate the complete capture attempt
  10. Replay one attempt across its boundaries
  11. Keep prompts and logs aligned with the contract
  12. Frequently asked questions
  13. Ship one explicit capture contract

Define the field before choosing a modality

An identifier is usually a string with a field-specific grammar. If leading zeroes are significant, converting it into an integer destroys information. A synthetic value such as 01 must remain a two-character value through recognition, readback, confirmation, storage, and any permitted lookup.

Define the accepted character set and length before implementing speech or keypad handlers. The example accepts ASCII digits only and a bounded fixed length. That is a deliberately narrow application contract, not a claim that all identifiers worldwide consist of ASCII digits or have the same length.

Python's broad digit predicates can accept characters outside ASCII. The Python regular-expression documentation describes Unicode matching behavior and ASCII options. A field expecting characters zero through nine should make that restriction explicit instead of silently accepting another script and assuming downstream services interpret it the same way.

Speech normalization needs an equally clear contract. The reducer below expects an already interpreted candidate string; it does not turn arbitrary spoken phrases into digits. The speech adapter must distinguish a candidate, an ambiguous result requiring clarification, and an unsupported interpretation. It should not manufacture certainty by deleting every unfamiliar word.

Keep the field identity separate from a value. A late result from the previous prompt can contain a perfectly valid string and still be invalid for the current request. The state names an epoch and field. Restarting capture creates a new owner rather than clearing a global string and hoping old callbacks disappear.

The streaming partial-transcript guide explains why intermediate recognition evidence must not become business authority. A final transcript also needs field ownership and confirmation. Final means the recognizer finalized its segment; it does not mean the user approved an application action.

Model the attempt's terminal dispositions explicitly. Confirmed, canceled, expired, and overloaded mean different things to the next step. A silent empty value cannot explain whether the caller entered nothing, a deadline elapsed, or the controller refused further input after exhausting its bounded event history.

Keep the original field contract stable during an attempt. If the expected length changes, begin a new owned field. Mutating the rules after collecting some digits makes it unclear whether the existing candidate was validated under the current contract and whether a previous confirmation is still meaningful.

Separate a key press from transport reports

RFC 4733 defines RTP telephone events, including event duration and end reporting. Multiple reports can describe the same key press. Treating every received event packet as a new digit can turn one physical press into several characters.

A normalizer needs event identity, not just the digit value. Within the bounded example, identity is a source plus an already extended event timestamp. Repeated duration and final reports under that identity contribute one completed press. A new timestamp for the same digit represents a separate press and must remain separate.

The source field is a normalized stream identity, not a customer identifier. An adapter extending RTP timestamps must handle its own wrap and source-change rules. The tiny coalescer does not parse packets or invent an extended timestamp from a raw integer without session context.

Its payload contract is intentionally smaller than all RFC telephone-event possibilities. It accepts completed digit events for one supported field grammar, returns the maximum observed duration, and requires at least one end report. The duration remains in adapter-supplied telephone-event ticks; it is not a user-input latency measurement. Other event codes, long-event segmentation, transport validation, and packet scheduling remain adapter responsibilities.

Repeated reports of one DTMF press count once while a fresh press counts again

The fixture includes repeated final reports for one zero press, followed by another zero press at a new event timestamp. The result contains two digits, not one and not the number of packet reports. A changed digit reusing the same normalized identity is a conflict rather than a second accepted press.

Do not deduplicate by digit and time proximity alone. A caller can intentionally press the same key twice. A heuristic that collapses nearby identical digits can silently corrupt an identifier while appearing to reduce transport duplicates. Use the actual supported event identity and keep uncertainty explicit when the adapter cannot provide it.

A provider callback may already represent a normalized key press instead of a raw RFC event report. Read that interface before adding another coalescer. Applying packet-level deduplication assumptions to a provider's completed-event API can discard legitimate input. The SIP troubleshooting guide helps distinguish those observation boundaries.

Keep DTMF decoding separate from audio speech recognition. Some paths expose out-of-band events, while others require a documented in-band interpretation path. The PCMU and PCMA guide covers raw audio format correctness, which does not by itself establish that an interface exposes keypad events.

Choose an atomic ownership rule

The reference begins in voice mode and lets the first valid keypad digit select keypad ownership. That selection discards the voice candidate and begins a fresh digit sequence. After selection, late speech results receive a stale-modality disposition. They cannot append to or replace the keypad value.

This is an application policy for normalized events, not a universal description of mixed-input providers. A provider that chooses its own first-input winner may prevent your application from observing both paths. In that case, configure and test the provider's behavior and feed only its supported normalized outcome into the controller.

The exact tie rule matters. The example processes one closed ingress batch under a single owner. It orders events by a local ingress timestamp; at an equal timestamp, keypad events precede every other supported event kind, while ties within the same priority preserve supplied order. A final speech result and first valid keypad digit at that timestamp select keypad ownership in either supplied order.

Before sorting, the controller qualifies event timestamps as finite integers or floats, excluding booleans. Malformed timestamps still pass through the ownership, terminal-state, and deadline guards. A current unexpired field reports stale-age; malformed events cannot prevent valid dispatch or bypass matching-owner expiry. Returned outcomes follow supplied input order. An ingress batch must be a real scheduling boundary. The controller cannot retroactively claim that an earlier processed callback belonged to a simultaneous batch just because another event later arrived with a similar timestamp. The adapter must define how it closes a batch and which local clock assigns its timestamps.

One active modality produces a candidate before explicit confirmation

If you need a different tie policy, change it deliberately and rerun both orderings. A speech-preferred policy can also be coherent, but the code, prompt, provider configuration, and confirmation behavior must agree. An accidental callback race is not a policy.

The reference uses synthetic integer ticks for event age and deadline fixtures. They are local scheduling units, not RTP timestamps, carrier delay measurements, or a recommended user timeout. Choose real durations for your application through an accessible interaction design and an actual integration test.

Bound event history. Deduplication cannot retain arbitrary event identities forever in a long-running call. The example has a small synthetic history limit and returns an explicit overloaded terminal state when it is reached. A real adapter should set a justified bound and start a new owner at a supported field boundary.

Run the bounded identifier reducer

The complete Python 3.11-or-newer example uses immutable dataclasses, tuple histories, and pure state transitions. The asynchronous helper accepts a frozen confirmation Event and demonstrates a final current-state check after waiting. It assumes a single local owner serializes calls to the reducer and performs the final update without another asynchronous boundary.

The coalescer consumes already parsed telephone-event records. The reducer consumes already normalized field events. Neither validates a network signature or parses a protocol message. Those trust boundaries must be satisfied before data reaches these functions.

from dataclasses import dataclass, replace, FrozenInstanceError
import asyncio
import math


def ascii_digits(value):
    return type(value) is str and bool(value) and all('0' <= x <= '9' for x in value)


def completed_presses(packets):
    # Already parsed RFC 4733 records; no timestamp-wrap extension here.
    # source, extended event timestamp, digit, duration ticks, ended
    seen = {}
    order = []
    for source, stamp, digit, duration, ended in packets:
        if (type(source) is not str or not source or type(stamp) is not int or stamp < 0
                or not ascii_digits(digit) or len(digit) != 1
                or type(duration) is not int or duration < 0 or type(ended) is not bool):
            raise ValueError('invalid normalized telephone event')
        key = (source, stamp)
        previous = seen.get(key)
        if previous and previous[0] != digit:
            raise ValueError('conflicting event identity')
        if previous is None:
            order.append(key)
            previous = (digit, 0, False)
        seen[key] = (digit, max(duration, previous[1]), ended or previous[2])
    return tuple((key, seen[key][0], seen[key][1])
                 for key in order if seen[key][2])


@dataclass(frozen=True)
class Event:
    epoch: int
    field: str
    identity: str
    at: int | float
    kind: str
    value: str = ''
    version: int = 0


@dataclass(frozen=True)
class Field:
    epoch: int
    field: str
    deadline: int
    length: int
    mode: str = 'voice'
    value: str = ''
    version: int = 0
    status: str = 'active'
    seen: tuple = ()


def begin(epoch, field, deadline, length):
    if (type(epoch) is not int or epoch < 0 or type(field) is not str or not field
            or type(deadline) is not int or deadline < 0
            or type(length) is not int or not 1 <= length <= 32):
        raise ValueError('invalid bounded field')
    return Field(epoch, field, deadline, length)


def valid_timestamp(value):
    return type(value) is int or (type(value) is float and math.isfinite(value))


def apply(state, event, now):
    if type(now) is not int or now < 0:
        raise ValueError('invalid local clock')
    if state.status != 'active':
        return state, state.status
    if event.epoch != state.epoch or event.field != state.field:
        return state, 'stale-owner'
    if now >= state.deadline:
        return replace(state, status='expired'), 'expired'
    if not valid_timestamp(event.at) or event.at > now or now - event.at > 20:
        return state, 'stale-age'
    if type(event.identity) is not str or not event.identity:
        return state, 'invalid'
    if (type(event.value) is not str or type(event.version) is not int
            or event.kind not in ('key', 'speech', 'clear', 'confirm', 'cancel')):
        return state, 'invalid'
    signature = (event.kind, event.value, event.at, event.version)
    history = dict(state.seen)
    if event.identity in history:
        return state, 'duplicate' if history[event.identity] == signature else 'conflict'
    if len(history) >= 64:
        return replace(state, status='overloaded'), 'overloaded'
    # Remember supported events even if modality or format rejects their value.
    history[event.identity] = signature
    state = replace(state, seen=tuple(history.items()))
    if event.kind == 'cancel':
        return replace(state, status='canceled'), 'canceled'
    if event.kind == 'clear':
        return replace(state, value='', version=state.version + 1), 'cleared'
    if event.kind == 'confirm':
        if event.version != state.version:
            return state, 'stale-confirmation'
        if len(state.value) != state.length:
            return state, 'incomplete'
        return replace(state, status='confirmed'), 'confirmed'
    if event.kind == 'speech':
        if state.mode != 'voice':
            return state, 'stale-modality'
        if not ascii_digits(event.value) or len(event.value) != state.length:
            return state, 'invalid-value'
        return replace(state, value=event.value, version=state.version + 1), 'candidate'
    if not ascii_digits(event.value) or len(event.value) != 1:
        return state, 'invalid-value'
    value = (state.value if state.mode == 'keypad' else '') + event.value
    if len(value) > state.length:
        return state, 'too-long'
    return replace(state, mode='keypad', value=value,
                   version=state.version + 1), 'candidate'


def apply_batch(state, events, now):
    # Dispatch qualified events in timestamp/priority order. Report outcomes
    # in supplied input order, preserving all ownership and deadline guards.
    if type(now) is not int or now < 0:
        raise ValueError('invalid local clock')
    owned = tuple(events)
    qualified = [(i, event) for i, event in enumerate(owned)
                 if valid_timestamp(event.at)]
    ordered = sorted(qualified,
                     key=lambda item: (item[1].at,
                                       0 if item[1].kind == 'key' else 1,
                                       item[0]))
    outcomes = ['stale-age'] * len(owned)
    for index, event in ordered:
        state, outcomes[index] = apply(state, event, now)
    for index, event in enumerate(owned):
        if not valid_timestamp(event.at):
            state, outcomes[index] = apply(state, event, now)
    return state, tuple(outcomes)


async def confirm_after_wait(get_state, set_state, expected, request, wait, now):
    # Allocate a new immutable Event identity for each new request. An exact
    # retry reuses this same event, including its at and version fields.
    if (request.kind != 'confirm'
            or (request.epoch, request.field, request.version)
            != (expected[0], expected[1], expected[3])):
        return 'stale-confirmation'
    await wait()
    current = get_state()
    if current.status != 'active':
        return current.status
    if (current.epoch, current.field, current.mode, current.version) != expected:
        return 'stale-confirmation'
    current, outcome = apply(current, request, now())
    set_state(current)
    return outcome


def fixtures():
    packets = [('s', 100, '0', 10, False), ('s', 100, '0', 30, True),
               ('s', 100, '0', 30, True), ('s', 200, '0', 30, True)]
    presses = completed_presses(packets)
    assert presses == ((('s', 100), '0', 30), (('s', 200), '0', 30))
    try:
        completed_presses([('s', 100, '0', 1, True), ('s', 100, '1', 2, True)])
    except ValueError:
        pass
    else:
        raise AssertionError('changed event identity accepted')
    start = begin(1, 'synthetic', 100, 2)
    key = Event(1, 'synthetic', 'press-a', 10, 'key', '0')
    speech = Event(1, 'synthetic', 'speech-a', 10, 'speech', '99')
    for batch in ((speech, key), (key, speech)):
        state, outcomes = apply_batch(start, batch, 10)
        assert state.mode == 'keypad' and state.value == '0'
        assert 'stale-modality' in outcomes
    state, _ = apply(start, key, 10)
    assert apply(state, key, 10)[1] == 'duplicate'
    assert apply(state, replace(key, value='1'), 10)[1] == 'conflict'
    state, _ = apply(state, Event(1, 'synthetic', 'press-b', 11, 'key', '0'), 11)
    assert state.value == '00'
    previous = state
    state, _ = apply(state, Event(1, 'synthetic', 'clear', 12, 'clear'), 12)
    assert apply(state, Event(1, 'synthetic', 'confirm-old', 13, 'confirm',
                              version=previous.version), 13)[1] == 'stale-confirmation'
    for value in ('', '9', 'a', '00'):
        assert apply(start, Event(1, 'synthetic', 'bad', 10, 'key', value), 10)[1] == 'invalid-value'
    assert apply(start, replace(key, epoch=0), 10)[1] == 'stale-owner'
    assert apply(start, replace(key, field='old'), 10)[1] == 'stale-owner'
    assert apply(start, key, 31)[1] == 'stale-age'
    expired, outcome = apply(start, replace(key, at=100), 100)
    assert outcome == 'expired' and apply(expired, key, 10)[1] == 'expired'
    cancel = Event(1, 'synthetic', 'cancel', 10, 'cancel')
    closed, _ = apply(start, cancel, 10)
    assert apply(closed, key, 10)[1] == 'canceled'
    full, _ = apply(start, Event(1, 'synthetic', 's', 10, 'speech', '01'), 10)
    confirmed, outcome = apply(full, Event(1, 'synthetic', 'c', 11, 'confirm',
                                          version=full.version), 11)
    assert outcome == 'confirmed' and confirmed.value == '01'
    assert apply(confirmed, cancel, 12)[1] == 'confirmed'
    assert apply(full, Event(1, 'synthetic', 'k1', 11, 'key', '1'), 11)[0].value == '1'
    assert apply(full, Event(1, 'synthetic', 'k2', 11, 'key', '2'), 11)[0].mode == 'keypad'
    assert apply(start, Event(1, 'synthetic', 'c', 10, 'confirm'), 10)[1] == 'incomplete'
    overloaded = replace(start, seen=tuple((str(i), ('clear', '', i, 0)) for i in range(64)))
    assert apply(overloaded, key, 10)[1] == 'overloaded'
    try:
        full.value = '99'
    except FrozenInstanceError:
        pass
    else:
        raise AssertionError('mutable field')
    async def race(expire=False):
        box = [full]
        def update(s): box[0] = s
        async def wait():
            if not expire:
                box[0], _ = apply(box[0], cancel, 11)
            await asyncio.sleep(0)
        expected = (full.epoch, full.field, full.mode, full.version)
        request = Event(full.epoch, full.field, 'confirm-race', 12,
                        'confirm', version=full.version)
        outcome = await confirm_after_wait(lambda: box[0], update, expected,
                                           request, wait, lambda: 100 if expire else 12)
        assert outcome == ('expired' if expire else 'canceled')
    asyncio.run(race())
    asyncio.run(race(True))
    for bad_at in ('10', True, float('nan'), float('inf'), None):
        bad = replace(key, identity='bad-clock', at=bad_at)
        actual, outcomes = apply_batch(start, (bad, key), 10)
        assert actual.value == '0' and outcomes == ('stale-age', 'candidate')
        expired, outcomes = apply_batch(start, (bad,), start.deadline)
        assert expired.status == 'expired' and outcomes == ('expired',)
        foreign = replace(bad, epoch=start.epoch + 1)
        unchanged, outcomes = apply_batch(start, (foreign,), start.deadline)
        assert unchanged == start and outcomes == ('stale-owner',)
    fractional, _ = apply_batch(start, (replace(key, at=9.5),), 10)
    assert fractional.value == '0'
    async def confirmation_recovery():
        box = [start]
        def update(value): box[0] = value
        async def wait(): await asyncio.sleep(0)
        def token():
            state = box[0]
            return (state.epoch, state.field, state.mode, state.version)
        first_token = token()
        first = Event(1, 'synthetic', 'request-a', 10, 'confirm', version=0)
        assert await confirm_after_wait(lambda: box[0], update, token(),
                                        first, wait, lambda: 10) == 'incomplete'
        assert await confirm_after_wait(lambda: box[0], update, token(),
                                        first, wait, lambda: 11) == 'duplicate'
        changed = replace(first, at=11)
        assert await confirm_after_wait(lambda: box[0], update, token(),
                                        changed, wait, lambda: 11) == 'conflict'
        for identity, at, digit in [('new-a', 11, '0'), ('new-b', 12, '1')]:
            box[0], _ = apply(box[0], Event(1, 'synthetic', identity, at, 'key', digit), at)
        assert await confirm_after_wait(lambda: box[0], update, first_token,
                                        first, wait, lambda: 13) == 'stale-confirmation'
        second = Event(1, 'synthetic', 'request-b', 13, 'confirm', version=box[0].version)
        assert await confirm_after_wait(lambda: box[0], update, token(),
                                        second, wait, lambda: 13) == 'confirmed'
        assert box[0].value == '01'
    asyncio.run(confirmation_recovery())
    print('synthetic identifier ownership fixtures passed')

if __name__ == '__main__':
    fixtures()

Run the entire fence with your supported interpreter. The fixture covers leading zeroes, repeated transport reports, genuinely repeated presses, changed identities, equal-time arbitration in both supplied orders, stale modality, old owners, stale age, expiry, cancellation, bounded history, and invalid digit forms.

The immutable field owns strings and tuples rather than a caller's mutable dictionary. A previously returned state cannot be changed by rebinding its value property. The event batch is also copied into a tuple before ordering, so the reducer does not sort or mutate the caller's list.

The example deliberately keeps the confirmation helper local. It does not perform a business lookup or remote action. Its result establishes that the current local field passed the declared confirmation checks at that boundary. External action authorization belongs to a separate workflow with its own current proposal and policy.

Confirm a current snapshot

Read back the candidate under a versioned field identity. The user must be confirming the value the controller currently holds, not a string captured by an older callback. The reducer increments its candidate version on a material value operation and requires confirmation to name that version.

A new confirmation request needs a distinct identity. An exact retry reuses the same frozen Event, including its timestamp and candidate version, rather than rebuilding its payload from current time. An incomplete request can therefore be retried unchanged or followed by a new request after repair.

A correction invalidates an old confirmation. Clearing the field increments its version even though the new value is empty. The fixture then submits a confirmation for the previous version and receives stale-confirmation. That distinction prevents an earlier readback from approving newly entered digits.

An asynchronous operation can invalidate a candidate while the controller waits. The caller captures epoch, field, mode, and candidate version together with the immutable request; an exact retry retains both. The helper waits, then fetches the current state again. It checks terminal disposition and ownership before applying confirmation, and the reducer checks deadline immediately before updating state.

Current owner, field, modality, and deadline are checked before accepting input

If another await is introduced after those checks, perform the checks again at the next admission boundary. The response cancellation guide explains the same stale-work problem for speech output. An old completion cannot acquire fresh authority merely because the work finished successfully.

A confirmed field is terminal for this bounded capture attempt. Later input returns that disposition and does not edit the confirmed value. To correct it, begin a new owner and create a new proposal for any dependent action. Do not quietly reopen the same state while leaving downstream approval intact.

Terminal monotonicity also applies to cancellation and expiry. A retry of an old input can provide historical evidence, but it must not restore an active field. The reducer checks terminal status before duplicate history, so an exact retry receives the current terminal disposition rather than reviving a cached candidate.

The tool-argument revision guide develops the corresponding action boundary. Input capture and side effects need separate identities. A correctly confirmed string can be reused only according to the business workflow's actual authorization and idempotency rules.

Design corrections and recovery

Tell the caller which input path is active and how to correct it. A prompt that invites speech and keypad input but silently ignores one after selection creates avoidable confusion. Keep prompt behavior aligned with the provider's first-input policy and the application's field owner.

A terminator is control input, not an identifier digit. The reference does not assign a keypad terminator; its adapter must translate a supported finish control into an explicit confirmation request or another declared action. If the candidate is incomplete, confirmation returns incomplete rather than silently padding or submitting it.

Clear is also a control operation. It invalidates the old candidate version and retains the selected modality in this small policy. Switching back to voice requires a new field owner in the reference contract. That avoids treating an old speech result as fresh after a clear operation.

Do not force every identifier through one long prompt. A repeat option, a clear option, a supported modality change, and an alternate route can address different failures. The confirmation prosody guide explains how spoken values can be made easier to distinguish without changing their underlying data.

Recovery choices repeat, clear, switch modality, or offer an alternate route

The speaking-rate guide is relevant when a caller needs a slower readback. Adjusting presentation should preserve identifier characters and candidate identity. A slower confirmation does not justify changing the field deadline invisibly or keeping an obsolete candidate active forever.

Treat expiry as a recovery event. Tell the application whether it should offer another attempt, route to assistance, or stop according to policy. Do not let a timeout become an empty string that downstream code mistakes for a caller-submitted value.

Cancellation is different from an invalid digit. An invalid digit can leave the active field unchanged with an explicit rejection. A cancellation ends the attempt. If the user asks to start again, allocate a new epoch so old callbacks cannot enter the new attempt under a recycled field identity.

Respect provider and accessibility boundaries

Read the exact media interface. Current Twilio Media Streams message documentation describes inbound DTMF messages for bidirectional streams. Do not infer that every stream mode exposes the same messages or that outbound DTMF transmission is supported through that message type.

Gather and a bidirectional media stream are different interfaces. A developer asking to gather speech and digits concurrently may be trying to combine capabilities that the selected endpoint arbitrates internally. Another developer asks about an optional keypad choice before speech capture. These historical questions identify real integration concerns; they do not establish current endpoint behavior or support a zero-timeout workaround. Diagnose the provider contract before building an elaborate local reducer around events that will never arrive together.

Keep provider-specific assumptions visible in integration tests. Capture the documented input mode, finish behavior, callback type, and timeout settings. An integration that works with one provider configuration should not be presented as a portable telephony abstraction without testing the alternative contracts.

The W3C concurrent-input guidance is useful context for avoiding unnecessary input restrictions on web interfaces. It is web accessibility guidance at a particular conformance level, not a certificate that a telephone flow with speech and keypad choices is accessible to every caller.

Some callers cannot use a keypad, cannot hear a spoken readback, cannot provide usable speech in the current environment, or need more time. Provide a supported alternate route and test it with the relevant users and devices. Listing two modalities is insufficient if neither is usable for the person in front of the system.

The mobile voice latency guide describes device conditions that a server-only fixture omits. If the flow begins in a browser before reaching telephony, test focus, permissions, microphone availability, and the practical input controls at that surface too.

The criteria table uses ✓ to mean the method can establish the property under its declared contract. A ✗ means it cannot establish the property by itself. These are evidence comparisons, not accessibility certifications or measured success rates.

Method Preserve leading zeroes Separate repeated reports Confirm current field Authenticate caller
String field with explicit grammar ✓ ✗ ✗ ✗
Normalized telephone-event identity ✓ with string storage ✓ within adapter scope ✗ ✗
Versioned single-owner reducer ✓ ✓ for supplied identity ✓ at local boundary ✗
Provider signature validation ✗ by itself ✗ by itself ✗ ✗

Voice and keypad have different interpretation, correction, and access constraints

Protect the business action

A captured identifier is data. It can select a record or contribute to a workflow, but its presence does not prove the caller owns that record. DTMF is another input channel, not an authentication factor by itself. Speech confidence does not supply missing identity evidence either.

The current Twilio webhook security documentation describes verification of requests from that provider. A valid signature helps establish the request's source and integrity under the provider contract. It does not establish the caller's identity or grant permission for a business action.

Verify provider requests before normalizing events, then enforce your own call/session ownership and business authorization. Those checks belong at different boundaries. A signed old callback from a previous field can still be stale for the current attempt, even though its signature is valid.

The shared-phone identity guide discusses the broader boundary between media interaction and privileged action. Keep sensitive lookups and mutations behind explicit policy. Do not let a plausible identifier or an enthusiastic spoken yes bypass a required authorization step.

Avoid logging the full identifier just to debug arbitration. Record safe event identities, selected modality, field version, and dispositions where those are sufficient. If sensitive input must be retained for a justified purpose, apply its own access and retention controls instead of copying it into general traces.

The observability guide shows how to make a trace useful without making every log a content archive. A synthetic fixture can demonstrate duplicate handling and ownership races while containing no actual caller value. Preserve that advantage when sharing incident evidence.

When an action depends on multiple fields, confirm the complete current proposal. Confirming one identifier does not approve a different date, destination, or amount added later. The confirmation repair guide explains why material proposal changes invalidate earlier approval.

For an application using 60db Agent, attach this controller at the documented input boundary available to the application. It is a generic reference contract, not a claim about private speech arbitration or internal authentication behavior. The integration still owns its provider adapter and business policy.

Evaluate the complete capture attempt

Evaluate attempts from prompt start, not only successful confirmations. Keep the offered field population, selected modality, invalid inputs, stale events, corrections, expiry, cancellation, overload, and final disposition. A completion percentage that excludes abandoned or expired attempts can conceal the interaction's actual cost.

The outcome metrics guide explains why denominators need named boundaries. A correctly captured candidate and a completed authorized task are separate outcomes. Report them separately so a reliable keypad path does not acquire credit for an action that later failed policy checks.

Use synthetic race fixtures before live integration trials. Deliver repeated final reports, new same-digit presses, equal-time speech and key events in both supplied orders, old field results, and confirmation after correction. The complete reference fence makes those local guarantees repeatable without a carrier.

Then test the actual endpoint with consented, bounded scenarios. Confirm which events it emits, how it chooses the first modality, and what happens when the caller changes their mind. The local reducer cannot prove the provider's callback ordering, physical keypad behavior, or acoustic interpretation from its synthetic fixtures alone.

The voice regression guide provides a way to preserve those scenarios as repeatable cases. Keep expected dispositions alongside fixture identities. If the provider configuration changes, rerun the affected integration cases rather than assuming the same event contract still holds.

Use field-focused scoring. Exact identifier match can be appropriate under an explicit normalization policy, but do not erase meaningful leading zeroes to improve a score. Track correction attempts and time to a confirmed candidate separately. State which observations are unavailable rather than substituting a guessed value.

The multilingual benchmark guide helps organize language conditions when the voice path supports more than one language. DTMF may reduce some recognition ambiguity, but it does not eliminate prompt comprehension, device access, correction, or authorization problems.

Review stale-input dispositions as evidence of boundary behavior, not automatically as user errors. A late speech result after keypad selection can be expected during a valid transition. Count it in the relevant adapter or controller trace without treating the caller as having submitted an invalid identifier.

Replay one attempt across its boundaries

Build a synthetic replay record from the prompt boundary through final disposition. Keep the field contract, local owner identity, input events, arbitration batch boundaries, and expected outcomes together. A list of digit values without the mode transition cannot reproduce a stale-speech defect.

Start with a voice candidate that is valid under the field grammar. Deliver a first keypad digit before confirmation and verify that the candidate resets to the keypad sequence. This is a material value and owner change. A previously prepared readback must no longer be eligible to confirm the old voice value.

Next deliver the old final speech result again. Its contents may still be valid digits, and its network callback may still be authentic. The correct local result remains stale-modality because the keypad owns this attempt. That fixture isolates application ownership from transport authenticity and field grammar.

Then deliver a second press of the same key under a fresh event identity. Confirm that both characters remain in the candidate, including a leading zero when present. Replay the first press's final report again and confirm it does not add a third character. This pairing catches both excessive deduplication and insufficient deduplication.

Submit a confirmation naming the current candidate version, then deliver a delayed clear or key event. The confirmed attempt remains terminal. If the interaction needs correction after confirmation, begin a new attempt and invalidate dependent approval through the workflow. Editing the terminal string would break the relationship between the readback and the value approved.

Repeat the scenario with cancellation during an asynchronous confirmation wait. Keep the local field state in one owned location so the post-wait check reads the actual current state. The callback's captured value is historical evidence. It cannot overwrite the canceled state simply because its awaited operation returned without error.

Repeat it with the deadline reached during the same wait. The controller should return expired, preserve that disposition on retries, and invoke the declared recovery path. Do not translate expiry into a successful confirmation of an empty value or reuse an earlier candidate because it is still available in memory.

The replay should also test a new attempt while old events are still outstanding. Allocate a fresh epoch, send a valid new candidate, and then deliver an old event. Reject the old owner before applying current-attempt expiry or other side effects. Otherwise stale work could accidentally terminate the newer attempt.

Keep prompts and logs aligned with the contract

A prompt should explain the supported choices in terms the caller can act on. If selecting keypad ends speech capture, say so through the interaction design. Avoid promising that the application will combine spoken and keyed fragments when the selected provider gives the first detected modality precedence.

Explain corrections before the caller needs them. A caller entering a long identifier should know whether a clear control removes the entire candidate or one character, whether a finish control submits immediately or begins readback, and how to repeat the instructions. Implement only the behavior you describe and retain an explicit outcome for unsupported controls.

Keep user pacing separate from transport event duration. RFC telephone-event duration describes the reported press event; it is not a complete measure of how much time the caller needs to understand a prompt or locate a keypad. A local stale-event budget and a human interaction timeout serve different purposes and need different tests.

Safe operational logs can record that a field changed from voice to keypad, a duplicate press was suppressed, or a confirmation became stale. Those facts often explain the defect without retaining the identifier itself. Avoid logging both a masked display and an unmasked original in another routine field, which defeats the purpose of masking.

Do not derive a success score from the count of accepted key events. An accepted event can contribute to an incomplete field; a confirmed field can belong to a workflow that later fails authorization. Preserve those outcome boundaries so product decisions reflect actual capture and task behavior rather than convenient low-level counts.

When reviewing a provider migration, compare the old and new event contracts directly. One may expose completed digits, another packet-derived reports, and another only a final gathered string. Adapt each through its documented boundary instead of forcing all three to pretend they supply the same event identities.


Frequently asked questions

Does DTMF authenticate the caller?

No. Keypad input can communicate an identifier or control, but it does not prove who entered it or what they are authorized to do. Apply the business workflow's identity and authorization requirements separately. A provider-signed event does not change that distinction.

Why store an identifier as a string?

A string preserves leading zeroes and the exact accepted character sequence. Numeric conversion can silently change the value before confirmation or lookup. Validate the field grammar directly, and keep presentation formatting separate from the canonical stored string.

Can speech and keypad update one field concurrently?

They can feed one controller only under an explicit arbitration contract supported by the provider interface. Uncoordinated callbacks must not mutate the same candidate independently. The reference selects keypad ownership on the first valid key and rejects later speech results for that attempt.

What happens when final speech and the first key tie?

Within one closed ingress batch, equal local timestamps place keypad input before speech. The first valid key selects keypad ownership, so the speech result becomes stale-modality in either supplied order. The adapter must define a real batch boundary rather than retroactively inventing simultaneity.

Do repeated RFC telephone-event packets mean repeated digits?

No. Duration and final reports can describe one press. Coalesce them under a supported event identity before producing a field event. A fresh press of the same digit with a new event timestamp remains a separate press and must not be discarded.

Why not deduplicate by digit value?

A caller can intentionally repeat the same digit, including leading zeroes. Value-only deduplication destroys that input. Use the adapter's supported press identity, distinguish duplicates from conflicts, and retain uncertainty when the interface cannot supply a reliable identity.

Can I feed raw RTP packets to the reducer?

No. The reference consumes normalized events after protocol parsing and trust checks. Its small coalescer expects already parsed, bounded telephone-event records with extended timestamps. RTP parsing, timestamp wrap, source changes, and transport validation remain adapter responsibilities.

What does a changed event identity mean?

An exact retry under the same identity is a duplicate. Reusing that identity with changed supported content is a conflict. Treating the changed payload as another accepted press can corrupt the identifier. The fixture makes those outcomes explicit rather than silently picking a winner.

Why reject non-ASCII digit characters?

The synthetic field contract accepts characters zero through nine because that is its declared downstream grammar. Other fields can support other character sets through an explicit normalization policy. Do not rely on a broad Unicode digit predicate when the destination expects ASCII.

Can the example understand spoken number phrases?

No. The speech adapter supplies an already interpreted candidate string or an explicit ambiguous disposition. The reducer validates ownership and grammar. Arbitrary phrase-to-digit interpretation requires its own language-specific contract and tests, especially for identifiers whose grouping matters.

What happens to speech after keypad selection?

It receives stale-modality and cannot replace or append to the keypad candidate. Returning to voice requires a new owned attempt under this reference policy. That keeps an old recognizer completion from becoming valid merely because a local string was cleared.

Does clearing the field preserve confirmation?

No. Clear increments the candidate version and invalidates any confirmation naming the previous version. The reference retains the selected modality after clear. If the application needs a mode switch, create the corresponding new owner and test its stale-input boundary.

What if the caller submits too few digits?

Confirmation returns incomplete and leaves the active field available for the declared recovery flow. It does not pad or guess missing characters. The prompt should explain how to continue, clear, repeat, or use another supported route without hiding the incomplete disposition.

What does expiry return?

It returns an explicit expired terminal state rather than an empty string. Later retries retain that disposition. The application can then choose a supported recovery action, but a retry must not silently reactivate the old attempt or submit its stale candidate.

Can cancellation happen during a confirmation wait?

Yes. The helper takes an immutable confirmation request, fetches current state after the await, and checks terminal disposition and expected ownership again. The fixture cancels during the wait and verifies that confirmation stays canceled. Adding another asynchronous boundary requires another final check.

Is a signed webhook enough to accept an identifier?

It helps establish the provider request under that interface's signature contract. The application still needs current call, field, modality, age, and business-policy checks. A validly signed callback can be stale or irrelevant to the current capture attempt.

Does offering speech and keypad guarantee accessibility?

No. A caller may be unable to use either path in the current environment. Provide and test an actual supported alternate route. Web concurrent-input guidance is useful context, but it does not certify a telephone interaction or replace user and device evaluation.

Why bound deduplication history?

Unbounded event identities can create unbounded memory and work during a long call. The synthetic reducer has an explicit history limit and overload disposition. A deployment should choose a justified bound and a supported new-field policy rather than silently dropping arbitrary history.

Can a confirmed value be edited in place?

Not in this contract. Confirmation is terminal for the attempt. A correction begins a new owner and invalidates any dependent approval according to the workflow. Otherwise downstream action code could use an approval for a value the caller never confirmed.

What should be tested on a real provider?

Test the documented input mode, first-input arbitration, event identity, callback delivery, finish controls, timeout behavior, and practical device interaction. Synthetic fixtures establish local logic only. Preserve provider-specific outcomes separately from physical-device and authorized-task evidence.

Ship one explicit capture contract

Choose a field grammar and preserve its value as a string. Normalize supported transport events into genuine presses, then let one owner arbitrate input and hold the current candidate. Make correction, confirmation, expiry, cancellation, and overload explicit.

Run the complete local fixture before integrating callbacks. Then test the actual provider and accessible recovery routes with consented scenarios. Keep captured input separate from caller authentication and business authorization, so a correct identifier cannot accidentally become permission for an unrelated action.