← Payments and SEPA: project and operational decisions
07 / 10 · 60 MIN

Workshop: VOP contract and ambiguous responses

Diagnose incompatible responses and build a bounded consumer that preserves the difference between business outcome and technical error.

Pin documents and scope

This workshop pins VOP API 1.1.1, effective on 20 September 2026. The applicable rulebook has its own version, 1.1. Record both as dependencies: forcing equal version numbers would lose the identity of the implemented document. The code is an original teaching consumer of invented responses. It is not a complete client, matching algorithm, or security implementation. ACCOUNT-A is a local marker, not an IBAN. Before adapting this exercise, identify which components remain to be implemented and who accepts each integration component.

Check the expected branch

Prepare two fixture families: name and legal-person identifier. The response must correspond to the requested branch. The name branch admits MTCH, NMTC, CMTC, and NOAP; the identification branch does not admit CMTC. Mixing both fields causes rejection in the exercise. For CMTC, matchedName accompanies the result. The consumer limits the name to 140 characters and requires nonblank text, the latter being a local usability choice. It does not check the complete permitted character set. The learner should explain exactly what the test established without calling it full schema validation.

Preserve the difference between error and matching

A fictional team counts every received response as a positive check. The first diagnostic step is to separate transport from outcome. The contract uses application/json for success and application/problem+json for detailed errors. The model requires text type and code fields for errors without implementing every official restriction on those fields. A 503 error remains API_ERROR; it is not converted into NMTC. That distinction changes investigation ownership: technical unavailability does not establish that a customer entered an incorrect name. The dashboard should count failures separately from business outcomes while preserving the request population used.

Reject ambiguity before interpretation

A fixture with two partyNameMatch members contains MTCH first and NMTC second. A library retaining the last value hides the original conflict. The exercise uses object_pairs_hook to reject repeated members before creating the final object and parse_constant to reject NaN. The RFC recommends unique names; rejecting duplicates is an explicit consumer policy. There is also a local limit of 2048 text characters, not bytes. These checks serve controlled fixtures and do not prove resistance to hostile input. Introducing one defect at a time distinguishes parsing, object type, and business contract.

Design diagnostics with the supplier

In the Oficina Aurora DR case, the frontend promotes CMTC without a name to exact match. Ask the supplier for the minimal fixture, expected outcome, observed outcome, and transformation point. Do not fill the missing field with the request name: that would fabricate a responder observation. A negative test should accompany the correction to prevent regression. Also distinguish an identifier unavailable for a payee from a type unsupported in the directory: these are different conditions. The lab does not query that directory; this dependency belongs separately in the qualification plan and acceptance decision.

Run and explain the evidence

Reserve ten minutes to predict outcomes, twenty to run and alter fixtures, twenty to discuss failures, and ten to prepare handover. Copy the complete program below and run python3 run.py --output evidence.json in a local folder. It uses only the standard library. The evidence file records interpreter version, program hash, and forty checks, including request binding from the next lesson. Also retain predictions and observed discrepancies. Execution establishes these local cases; it establishes neither a PSP connection, an actual account lookup, nor payment authorization.

"""Original DR VOP response-consumer and draft-binding exercise. Python 3.13.
python3 run.py --output evidence.json
In-script fixtures only. No actual IBAN, account lookup, matching algorithm,
HTTP request, payment, authentication, full schema or scheme conformance.
Registry, draft revision, binding and decision labels are local DR rules.
"""
import argparse
import hashlib
import json
from pathlib import Path
import sys

checks=[]
def check(name,actual,expected):
 assert actual==expected,(name,actual,expected)
 checks.append(dict(name=name,actual=actual,expected=expected,passed=True))
def rejection(fn):
 try:fn
 except ValueError as e:return str(e)
 raise AssertionError('Expected rejection')
def pairs_unique(pairs):
 obj={}
 for k,v in pairs:
 if k in obj:raise ValueError('duplicate JSON member')
 obj[k]=v
 return obj
def invalid_constant(value):raise ValueError('non JSON constant')
def parse(body):
 if len(body)>2048:raise ValueError('local size limit')
 value=json.loads(body,object_pairs_hook=pairs_unique,parse_constant=invalid_constant)
 if not isinstance(value,dict):raise ValueError('expected object')
 return value

def consume(mode,http,media,body):
 if mode not in {'name','id'}:raise ValueError('unknown local mode')
 value=parse(body)
 media=media.split('')[0].strip.lower
 if http!=200:
 if media!='application/problem+json':raise ValueError('error media type')
 if not all(isinstance(value.get(k),str) and value[k] for k in ('type','code')):raise ValueError('incomplete problem')
 return dict(result='API_ERROR',displayName=None)
 if media!='application/json':raise ValueError('success media type')
 key='partyNameMatch' if mode=='name' else 'partyIdMatch'
 other='partyIdMatch' if mode=='name' else 'partyNameMatch'
 if key not in value or other in value:raise ValueError('response branch')
 result=value[key]
 allowed={'MTCH','NMTC','NOAP'}|({'CMTC'} if mode=='name' else set)
 if not isinstance(result,str) or result not in allowed:raise ValueError('result code')
 name=value.get('matchedName')
 if result=='CMTC':
 # Nonblank requirement is a local usability check; full character set omitted.
 if not isinstance(name,str) or not name.strip or len(name)>140:raise ValueError('close match name')
 elif 'matchedName' in value:raise ValueError('unexpected matched name')
 return dict(result=result,displayName=name)

def signature(draft):
 return tuple(draft[k] for k in ('revision','environment','mode','account','party'))
def register(registry,request_id,draft):
 snap=signature(draft)
 if request_id in registry and registry[request_id]!=snap:raise ValueError('request identity conflict')
 registry[request_id]=snap
def applicable(registry,request_id,current):
 return request_id in registry and registry[request_id]==signature(current)
def within_vop_limit(sent_ms,received_ms):
 elapsed=received_ms-sent_ms
 if elapsed<0:raise ValueError('invalid elapsed time')
 return elapsed<=5000

def main:
 def response(mode='name',http=200,media='application/json',**value):
 return consume(mode,http,media,json.dumps(value))
 for code in ('MTCH','NMTC','NOAP'):
 check('name '+code,response(partyNameMatch=code),dict(result=code,displayName=None))
 check('name close match',response(partyNameMatch='CMTC',matchedName='Oficina Aurora DR'),dict(result='CMTC',displayName='Oficina Aurora DR'))
 for code in ('MTCH','NMTC','NOAP'):
 check('id '+code,response(mode='id',partyIdMatch=code),dict(result=code,displayName=None))
 check('id close match rejected',rejection(lambda:response(mode='id',partyIdMatch='CMTC')),'result code')
 check('wrong branch rejected',rejection(lambda:response(partyIdMatch='MTCH')),'response branch')
 check('both branches rejected',rejection(lambda:response(partyNameMatch='MTCH',partyIdMatch='MTCH')),'response branch')
 check('missing close match name',rejection(lambda:response(partyNameMatch='CMTC')),'close match name')
 check('blank close match name',rejection(lambda:response(partyNameMatch='CMTC',matchedName=' ')),'close match name')
 check('long close match name',rejection(lambda:response(partyNameMatch='CMTC',matchedName='A'*141)),'close match name')
 check('name without close match rejected',rejection(lambda:response(partyNameMatch='MTCH',matchedName='Oficina Aurora DR')),'unexpected matched name')
 check('unknown code rejected',rejection(lambda:response(partyNameMatch='SUCCESS')),'result code')
 check('numeric code rejected',rejection(lambda:response(partyNameMatch=1)),'result code')
 check('duplicate JSON member rejected',rejection(lambda:consume('name',200,'application/json','{"partyNameMatch":"MTCH","partyNameMatch":"NMTC"}')),'duplicate JSON member')
 check('NaN rejected',rejection(lambda:consume('name',200,'application/json','{"partyNameMatch":NaN}')),'non JSON constant')
 check('array rejected',rejection(lambda:consume('name',200,'application/json','[]')),'expected object')
 check('local size bound rejected',rejection(lambda:parse(' '*2049)),'local size limit')
 check('media parameter accepted',response(media='application/json; charset=utf-8',partyNameMatch='MTCH')['result'],'MTCH')
 check('HTML success rejected',rejection(lambda:response(media='text/html',partyNameMatch='MTCH')),'success media type')
 problem=response(http=400,media='application/problem+json',type='urn:dr:training:invalid-request',code='DR-FIXTURE')
 check('problem remains API error',problem,dict(result='API_ERROR',displayName=None))
 check('wrong error media rejected',rejection(lambda:response(http=400,type='urn:dr:training:error',code='DR-FIXTURE')),'error media type')
 check('incomplete problem rejected',rejection(lambda:response(http=503,media='application/problem+json',type='urn:dr:training:error')),'incomplete problem')
 draft=dict(revision=1,environment='DR-TEST',mode='name',account='ACCOUNT-A',party='Oficina Aurora DR')
 registry={};register(registry,'DR-Q1',draft)
 check('response bound to unchanged draft',applicable(registry,'DR-Q1',draft),True)
 for key,value in [('revision',2),('environment','DR-OTHER'),('mode','id'),('account','ACCOUNT-B'),('party','Atelier Boreal DR')]:
 check('changed '+key+' invalidates observation',applicable(registry,'DR-Q1',{**draft,key:value}),False)
 check('unknown request isolated',applicable(registry,'DR-Q9',draft),False)
 register(registry,'DR-Q1',draft)
 check('same local registration unchanged',len(registry),1)
 check('identity conflict rejected',rejection(lambda:register(registry,'DR-Q1',{**draft,'account':'ACCOUNT-B'})),'request identity conflict')
 current={**draft,'revision':2,'account':'ACCOUNT-B'};register(registry,'DR-Q2',current)
 check('new response applies',applicable(registry,'DR-Q2',current),True)
 check('late old response stays stale',applicable(registry,'DR-Q1',current),False)
 check('within five seconds',within_vop_limit(1000,5999),True)
 check('at five-second boundary',within_vop_limit(1000,6000),True)
 check('beyond five-second boundary',within_vop_limit(1000,6001),False)
 check('negative duration rejected',rejection(lambda:within_vop_limit(1000,999)),'invalid elapsed time')
 output=dict(scope='Original bounded response-consumer and draft-binding fixtures; no actual matching, VOP API, full conformance, payment authorization or execution.',python=sys.version.split[0],runnerSha256=hashlib.sha256(Path(__file__).read_bytes).hexdigest,passed=len(checks),checks=checks)
 parser=argparse.ArgumentParser;parser.add_argument('--output',required=True)
 Path(parser.parse_args.output).write_text(json.dumps(output,indent=2)+'\n');print(json.dumps({'passed':len(checks),'scope':output['scope']}))
if __name__=='__main__':main
IN PRACTICE

Case: a supplier returns both branches in one response and the interface chooses the positive one. The rehearsal should reject the mixture and retain its originating request.

Common pitfalls

Treating accepted JSON as conformance evidence; turning an error into no match; filling absent names; confusing fixtures with a qualified integration.

Related topics: Timing, confirmations, and late outcomes · Mandates, Core, and B2B · Changes and operational readiness

Take this idea with you

A usable response requires interpreting branch, code, and fields in the correct context while preserving failures the consumer actually observed.

Create account

Reference: EPC VOP Inter-PSP API specifications 1.1.1 · BigSavant Payments and SEPA professional assessment2026.10