Appearance
Signing a contract
Obtaining an authorization, and binding it to the document that was shown.
Present, then sign
A contract is presented before it can be signed. Presenting renders the document, records a SHA-256 over the exact bytes, and keeps those bytes as an attachment — in one transaction, because a contract with a digest and no attachment is the unanswerable record the whole design exists to prevent.
| Status | Meaning |
|---|---|
draft | Being prepared. No document exists yet |
presented | Bound: version, digest and archived bytes all recorded |
void | Withdrawn or superseded, with a stated reason |
A presented contract must carry its binding, enforced by a database constraint as well as a validation — a nullable column guarded only by a validation is one save(validate: false) away from an unanswerable contract.
Voiding keeps the evidence
The legacy reset! destroyed the document unconditionally, so a cancelled contract left nothing to show a regulator who asked what had been sent. Voiding here changes the status and keeps the bytes.
What an attempt records
Each SigningAttempt records one request for an authorization, and the document it was requested against:
document_digest— the binding, andNOT NULLconsented_atwith IP and user agent — recorded by the serverchallenge— Nafath's number, which the signer must pick in their appprovider_claims— exactly what the provider returned, verified
The digest is copied onto the attempt rather than read through the contract. A contract can be voided and superseded; this attempt has to keep the digest that was on screen when this person was asked. still_describes_the_contract? answers whether it has since diverged.
Consent
Consent is timestamped by the server from the request that carried it.
What this replaces
The legacy flow took consent timestamps from client JavaScript. The signer's own machine decided when they had agreed — and a value the signer controls is not evidence against them.
An attempt cannot pass preparing without a recorded consent, enforced by constraint.
The states, kept distinct
| State | Meaning |
|---|---|
preparing | Consent recorded, provider not yet called |
waiting | Nafath has the request; the person has 180 seconds to act in the app |
completed | Approved, with the provider's claims |
rejected | The person declined |
expired | Nobody answered in time |
failed | Something on our side or the wire broke — states why |
abandoned | Nobody is waiting any more |
Why these are not one value
The legacy code collapsed every non-completion into rejected. An expired request, a network failure and a genuine refusal became the same record — which turns "our request timed out" into "the merchant refused to sign".
Only rejected is a statement about a person. A failed attempt must state why, enforced by constraint, because a failure with no reason is the record that later gets read as a refusal.
Attribution comes from the verified claims only. If the provider did not return a name, the attempt has no attributed name — the legacy code used the caller-supplied name, so the record said whatever the request said.
Where the person actually is
The approval happens in the Nafath app, not on a page Verity serves. The person opens Nafath, sees a request, picks the number that matches the one we showed, and approves — with biometrics, for the service types that require them.
So there is no in-page signature to capture, and nothing for Verity to build there.
What Nafath shows the person
The app displays the service type — from Elm's fixed list of banking actions — and a number. It does not display the contract. Nothing about the document reaches Nafath.
So the document has to be put in front of the merchant somewhere else, before the request is made. If it is not, then consented_at records a consent nobody gave, against a digest of bytes the signer never saw — and that is a worse record than having none, because it looks like evidence.
That step is a page that shows the archived document and posts back. It is built — see signing by link — and the same page is where a Nafath challenge number would be shown. What remains for Nafath is the request itself.
The service type
The request carries a service, which decides what the person sees in the app, how long they have, and whether biometrics are required. Elm publishes a closed list of about twenty, and every one is a banking action:
Login · OpenBankAccount · OpenAccount · VerifyUsedDevice · MoneyTransfer · AddRecipient · RequestDigitalServicesEnrollment · CreateCreditCard · PersonalLoan · RealEstateLoan · RentalLoan · VehicleLoan · ConsumerismLoan · HouseDevicesLoan · GuardianConsentforOpeningBankAccount · RecipientApproval · UpdateRecipientInformation, plus four …WithoutBio variants.
There is no contract-signing service type
None of these means "sign a merchant agreement". Which one Elm has provisioned for this is a question for Elm, so it is configuration (NAFATH_SERVICE_TYPE) rather than a constant, and an unlisted value is refused before the request is sent.
An earlier version of this client used DigitalSignature, which appears nowhere in the guide. Elm would have rejected it, and the failure would have looked like a credential problem.
Timeouts are 60 or 180 seconds depending on the type, and the WithoutBio variants skip biometric verification — which a merchant agreement should not.
The protocol
From the Elm integration guide (NafathApp Service Integration Guide v2.0.1):
| Call | |
|---|---|
POST /api/v1/mfa/request?local=&requestId= | {nationalId, service} → {transId, random} |
POST /api/v1/mfa/request/status | {nationalId, transId, random} → {status} |
GET /api/v1/mfa/jwk | Verification keys |
POST <our callback> | {token, transId, requestId} |
requestId is ours, generated before the call, and comes back on the callback — it is the only way to match a callback to the attempt that started it.
Two constraints from the guide:
- Two windows, not one. The user decision interval is the first 180 seconds and is the only time the person can act; the transaction lifetime is 200 seconds, after which a status call returns
400-034-051rather than a state. A call between the two can still report that nobody answered; one after 200s cannot report anything. Both mean expiry, not refusal - "The callback must be hosted inside Saudi Arabia." A deployment constraint, not something code can satisfy
The status-polling fallback is real and called. The legacy one referenced an undefined constant and raised NameError, so it had never run once.
Verifying the token
Every check below rejects rather than warning, and each covers a way a token can be genuine and still not mean what accepting it would imply.
| Check | What it stops |
|---|---|
| Algorithm allowlist (RS256) | alg comes from the sender. Honouring it is how none and HMAC-confusion work |
Key by kid | A token signed by a key that has been rotated out but is still published |
| Signature | The obvious one — and the return value is checked, not ignored |
iss | A token from something that is not Nafath |
aud | A genuinely Elm-signed token minted for a different service provider |
exp / nbf | Replay. 30 seconds of clock skew, and no more |
transId | A valid token about somebody else's transaction settling our attempt |
Written against OpenSSL, not a JWT library
The legacy verifier checked the signature and nothing else — no expiry, no issuer, no audience, no algorithm allowlist. That failure is a set of unset verification options, so an explicit list of refusals is auditable where a library call with the wrong flags looks correct.
An unrecognised status is passed through rather than judged here. What a status means is decided in one place — the callback's mapping — and judging it twice made that mapping's fallback unreachable.
The callback
POST /callbacks/nafath. It runs outside the operator stack: no session, no CSRF, no user to authorize against.
| Situation | Response |
|---|---|
| Verified completion, rejection or expiry | 200, attempt settled |
WAITING | 200, nothing settled — nothing was decided |
| Token fails verification | 422, and the attempt records why |
| Unrecognised status | 200, recorded as a failure naming the value |
| Already settled (redelivery) | 200, nothing changed |
Unknown requestId | 404 |
| Cannot reach Nafath for keys | 503, attempt stays open |
What this replaces
The legacy callback swallowed every error and returned 200, so a token that failed verification looked to Nafath exactly like one we had accepted — and nothing was recorded either way. It also had no idempotency, and Nafath may deliver more than once.
An unreachable key endpoint is our outage, so the attempt stays open and the sender is asked to retry. Recording a failure there would blame the signer for our problem.
What is needed to make it run
Nothing signs today. Required:
NAFATH_APP_ID
NAFATH_APP_KEY
NAFATH_SERVICE_PROVIDER_NAME # the aud claimSigningCoverage.operable?("SA") is false until the first two are set, and the refusal names them rather than claiming the integration is missing. Verification refuses rather than skipping the audience check when the third is absent — skipping is exactly what would let another provider's token through.
A staging deployment. Nafath cannot be exercised locally: Elm posts the callback to us, so it needs a reachable HTTPS endpoint, and the guide requires that endpoint to be hosted inside Saudi Arabia. Testing means a deployed staging environment with the callback URL registered with Elm — not localhost.
The document-presentation page is no longer outstanding: signing by link built it, and it is where a challenge number would be shown.
Built, configured, and signing elsewhere
Three different facts, and the signing providers screen shows them separately:
| Meaning | Whose job | |
|---|---|---|
| Not built | No integration exists | Engineering |
| Built, not configured | Credentials missing | Deployment |
| Signing in legacy system | Contracts exist, produced elsewhere | Migration |
Sending someone to a settings screen for a provider that does not exist, or to build one that is sitting there unconfigured, are both wasted days.