AI product engineer · Shared primitives
Match a payment receipt to the customer’s order
Check the order and amount in a payment receipt before a voice assistant announces that payment succeeded.
Key takeaways (3)
- Sending a payment link leaves an order pending until the processor returns a receipt that can be verified.
- Check the receipt against the requested order and amount before sharing a payment reference or announcing payment.
- Receipt checks prove response binding in this fixture; they do not establish card recording safety or payment compliance.
A voice assistant sends a customer a payment link. Before it says the order is paid, it needs a receipt from the payment service for that order and amount. A genuine receipt for another order would not answer the customer’s question.
This local exercise uses fictional orders and a public teaching key. You will test a receipt for the wrong order and inspect the checks that keep the requested order pending.
This offline exercise signs a receipt for the wrong order using the public fixture key. The shipped verifier keeps the requested order pending. Removing its explicit order comparison still leaves that refusal intact:
shipped order pending reference_present False
no_explicit_comparison order pending reference_present False
The expected-signature calculation also uses the requested order and amount. Only the deliberately broken variant that removes both bindings reports paid:
both_removed order paid reference_present True
These are excerpts of the unedited output below. No processor, model or speech provider is called. All payment records and signing keys are public teaching fixtures.
Sending the link leaves payment pending
Willow Shop and its processor are fictional. The fixture’s hosted-page payment contract is an exercise policy, not a payment standard. The tool sends a link by text or email to a contact on the customer’s account. Sending it creates a processor session; it does not prove payment.
The companion tests distinguish link delivery from a later verified receipt. Without a session, a status query returns pending even if a caller describes a payment. The all-zero-signature test also refuses a forged receipt.
Fetch the public source at this tutorial’s revision:
git clone https://github.com/RasaHQ/rasa-community-resources.git payment-receipt-lab
cd payment-receipt-lab
git checkout 4aa0c4419dc193fef7a969c12d59edcf720f2606
cd examples/mantle-voice-payment-boundary-gpt
Run the fixture suite from the project directory. It needs no licence or provider keys:
- Any system
python3 -m unittest discover -s tests -v
To reproduce the selected receipt, run these named tests from its tests directory:
cd tests
python3 -m unittest -v \
test_guard.ToolLogicTests.test_forged_receipt_does_not_verify \
test_guard.ToolLogicTests.test_unverified_receipt_stays_pending \
test_guard.ToolLogicTests.test_callers_word_is_not_a_receipt \
test_guard.ToolLogicTests.test_link_then_verified_receipt_in_a_later_turn
cd ..
Actual offline output:
test_forged_receipt_does_not_verify (test_guard.ToolLogicTests.test_forged_receipt_does_not_verify) ... ok
test_unverified_receipt_stays_pending (test_guard.ToolLogicTests.test_unverified_receipt_stays_pending) ... ok
test_callers_word_is_not_a_receipt (test_guard.ToolLogicTests.test_callers_word_is_not_a_receipt) ... ok
test_link_then_verified_receipt_in_a_later_turn (test_guard.ToolLogicTests.test_link_then_verified_receipt_in_a_later_turn) ... ok
----------------------------------------------------------------------
Ran 4 tests in 0.001s
OK
Elapsed time will vary. The full suite skips its Rasa speech-engine tests when Rasa is not installed. These selected tests exercise fixture logic, not a microphone, speech provider or model.
Bind the receipt to the order and amount
The verification function first checks the requested order and amount. It then checks a signature over the receipt’s reference, order, amount and status:
def _signature(reference: str, order: str, cents: int, status: str) -> str:
key = _DATA["receipt_signing"]["fixture_key"].encode()
return hmac.new(key, f"{reference}|{order}|{cents}|{status}".encode(), hashlib.sha256).hexdigest()
def verify_receipt(receipt: dict, order: str, cents: int) -> bool:
"""True only when the processor's receipt is for this order and amount and its signature verifies."""
if receipt.get("order") != order or receipt.get("amount_cents") != cents:
return False
expected = _signature(receipt.get("reference", ""), order, cents, receipt.get("status", ""))
return hmac.compare_digest(expected, str(receipt.get("signature", "")))
The field comparison stops a correctly signed receipt for a different order or amount from satisfying this request. The signature comparison catches changes to the signed values. Checking that a reference looks like a processor identifier would miss both distinctions.
This is standard HMAC verification with a public fixture key. It demonstrates response binding; it is not a real processor trust boundary or production signing-key design.

The receipt is checked after querying the service. A sent link remains pending. An unverifiable response remains pending too, even when it contains a plausible reference.
Test each request binding separately
Changing a field without updating the signature is a useful tamper test, but it does not isolate request binding. Here the simulated processor returns a correctly signed receipt for the wrong order or amount.
| Temporary verifier | Explicit requested-order and amount comparison | Signature calculated over | Result for a correctly signed mismatch |
|---|---|---|---|
| Shipped | Present | Requested order and amount | Pending; no reference |
| Comparison removed | Absent | Requested order and amount | Pending; no reference |
| Receipt fields used for signature | Present | Receipt’s own order and amount | Pending; no reference |
| Both changes | Absent | Receipt’s own order and amount | Paid; reference exposed |
Deleting one check leaves the other binding intact. A passing refusal test therefore does not prove that the explicit comparison is still present. Test each change separately, then the pair.
Run this from the selected project directory. It compiles temporary function variants in memory; it never edits the checked-out source. The last variant is intentionally broken teaching code.
import inspect
from lib import payments as p
source = inspect.getsource(p.verify_receipt)
binding = ' if receipt.get("order") != order or receipt.get("amount_cents") != cents:\n return False\n'
request = '_signature(receipt.get("reference", ""), order, cents, receipt.get("status", ""))'
receipt_fields = '_signature(receipt.get("reference", ""), receipt.get("order", ""), receipt.get("amount_cents", 0), receipt.get("status", ""))'
assert source.count(binding) == source.count(request) == 1
original_verifier = p.verify_receipt
for variant in ('shipped', 'no_explicit_comparison', 'receipt_fields_only', 'both_removed'):
changed = source
if variant in ('no_explicit_comparison', 'both_removed'):
changed = changed.replace(binding, '')
if variant in ('receipt_fields_only', 'both_removed'):
changed = changed.replace(request, receipt_fields)
namespace = dict(p.__dict__)
exec(compile(changed, '<temporary-verifier>', 'exec'), namespace)
p.verify_receipt = namespace['verify_receipt']
for field, value in (('order', 'WS-10528'), ('amount_cents', 14901)):
service = p.PaymentService('binding-exercise')
_, memory = p.prepare_secure_payment(service, 'WS-CUST-5521', [], True, 'WS-10517', 'text')
p.send_secure_payment_link(service, 'WS-CUST-5521', [], True, memory, 'WS-10517', 2)
original_report = service.processor_report
def report(session, turn):
result = original_report(session, turn)
receipt = {**result['receipt'], field: value}
receipt['signature'] = p._signature(receipt['reference'], receipt['order'], receipt['amount_cents'], receipt['status'])
return {**result, 'receipt': receipt}
service.processor_report = report
result = p.check_payment_status(service, 'WS-CUST-5521', 'WS-10517', 3)
print(variant, field, result['payment_status'], 'reference_present', 'processor_reference' in result)
assert result['payment_status'] == ('paid' if variant == 'both_removed' else 'pending')
p.verify_receipt = original_verifier
service = p.PaymentService('binding-exercise')
_, memory = p.prepare_secure_payment(service, 'WS-CUST-5521', [], True, 'WS-10517', 'text')
p.send_secure_payment_link(service, 'WS-CUST-5521', [], True, memory, 'WS-10517', 2)
control = p.check_payment_status(service, 'WS-CUST-5521', 'WS-10517', 3)
print('authentic_requested_receipt', control['payment_status'], control['processor_reference'], control['amount_spoken'])
assert control['payment_status'] == 'paid'
Actual unedited output, recorded on 8 October 2026:
shipped order pending reference_present False
shipped amount_cents pending reference_present False
no_explicit_comparison order pending reference_present False
no_explicit_comparison amount_cents pending reference_present False
receipt_fields_only order pending reference_present False
receipt_fields_only amount_cents pending reference_present False
both_removed order paid reference_present True
both_removed amount_cents paid reference_present True
authentic_requested_receipt paid QP-921154 149 dollars
The signature helper reads a public fixture key. Re-signing the response is a test control, not permission to create real processor receipts. The exercise passes an empty conversation record and an enabled redaction predicate to isolate payment verification; it does not test recording safety.
Withhold the reference when verification fails
The status tool’s business logic only adds the processor reference to a successful result. The pending result says the receipt could not be verified and the order stays on hold.
This choice costs convenience. A customer who says they have paid may still have to wait for the payments team. Sharing an unverified reference as proof would trade that delay for a false completion claim.
The Rasa adapter in skills/pay_order_balance/tools.py calls that logic and keeps the structured result:
async def check_payment_status(order_number: str, context: ToolContext = None) -> ToolResult:
"""Check the processor.
Args:
order_number: The order, for example WS-10517.
"""
result = wp.check_payment_status(_service(), _customer(context), order_number, _user_turn(context))
await _send_receipt(context, "check_payment_status", result)
return ToolResult(llm_response=result)
The adapter reads the customer from project memory and counts caller turns from tracker events. The fixture uses the later turn to simulate the processor response. A real processor integration would need its own evidence of completion; advancing a conversation turn cannot provide it.
The skill’s instructions tell the agent to query status when the caller claims payment. They also tell it not to repeat an outcome already sent by the tool. Instructions support the conversation, while Python verification decides what the result may claim.
Keep the successful control separate
The final line uses the restored shipped verifier and an unchanged receipt for the requested order. It produces paid. That distinguishes refusal of changed receipts from a status tool that simply refuses everything.
Show what the successful control returns
The restored result reports succeeded, verified_fixture_receipt and paid. It includes processor reference QP-921154 and the spoken amount, 149 dollars. These are synthetic fixture values, not a live payment.
The shipped verifier keeps both correctly signed mismatches pending and omits their references. Either isolated mutation also refuses them. The deliberately broken double mutation reports paid and exposes a reference; that is the failure the assertions must detect.
On 8 October 2026, the full suite also ran with the actual Rasa Pro 3.21.0.dev5 SDK installed, without skips. Project validation accepted the skills and tools. From the selected project, the commands are:
uv run --locked python -m unittest discover -s tests -v
make validate
Actual output excerpts:
Ran 48 tests in 0.008s
OK
validate_project: ok
These are offline SDK checks. No licensed training, model or speech-provider call ran for these receipts.
To try the voice path later, select only this project with Python 3.12. Its setup files pin Rasa Pro 3.21.0.dev5 and provide the environment template:
uv sync --locked --python 3.12
make env
# Fill .env locally with RASA_LICENSE, OPENAI_API_KEY,
# DEEPGRAM_API_KEY and RIME_API_KEY. Do not commit it.
make validate
make train
make inspect
The pinned integrations.yml selects GPT, a custom Deepgram transcript filter and Rime speech. Provider calls are billed. No live provider call was made for the evidence here.
Ask to pay order W S one zero five one seven by text. Then answer two acceptance questions from the Inspector: does link delivery leave the order pending? When you say you have paid, does the agent query status before reporting the result? Those checks observe your sampled conversation; they do not establish recording safety.