The first refusal from the gateway arrived as a parser crash, not as a declined payment.
We start transactions through our own open-source client, bluemedia. The happy path is simple: post a signed request, get back a transaction init document with a remote id and a redirect url, then send the user off to pay. That is what our code expected, so the response body went straight into the init model.
Then the gateway refused one. The answer came back under the same root element, carrying a service id, an order id and a confirmation element, with no remote id and no redirect url. Our parser did the only thing it could: it hit a field it had no slot for and raised an unknown element error. From the outside that reads like our bug, or a gateway outage. It was neither. It was a precise, well-formed decline that we had no type for.
The cost was an afternoon spent reading raw XML next to a gateway spec, only to find we had been told something exact in a language we never taught the client to hear.
What changed: the transaction root now carries two different documents, and start no longer guesses. It posts, reads the answer once, and asks the refusal model whether this root is a transaction holding the confirmation element, the one marker no successful answer declares. Only then does it pick a model.
The order of the steps mattered more than the parsing. Signature verification runs before the raise, not after. A refusal is signed over its own field order, so if the shared key does not reproduce the digest, start fails as a verification error that still carries the parsed refusal with it. An unsigned refusal fails the same way, because verification returns false when there is no hash at all. Otherwise "no signature" would quietly come to mean "nothing to disagree with". Only a refusal we could actually verify gets raised as a refusal: a typed gateway error exposing the refusal, the order id and the reason, with the message falling back to the confirmation value when the gateway sends no reason.
In the document, reason and hash are optional; service id, order id and confirmation are not. That split is the whole contract.
The habit we kept: when an integration has a negative answer, give it a type and verify it on the same terms as the positive one. A decline that arrives as a parse error teaches whoever is on call the wrong lesson about where the fault is.
We think signed error responses deserve the same strictness as success, which means an unverifiable decline is not a decline we act on. If you act on one anyway, you are trusting a message you cannot check.