Skip to content

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.

StatusMeaning
draftBeing prepared. No document exists yet
presentedBound: version, digest and archived bytes all recorded
voidWithdrawn 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, and NOT NULL
  • consented_at with IP and user agent — recorded by the server
  • challenge — Nafath's number, which the signer must pick in their app
  • provider_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 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 ​

StateMeaning
preparingConsent recorded, provider not yet called
waitingNafath has the request; the person has 180 seconds to act in the app
completedApproved, with the provider's claims
rejectedThe person declined
expiredNobody answered in time
failedSomething on our side or the wire broke — states why
abandonedNobody 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/jwkVerification 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-051 rather 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.

CheckWhat it stops
Algorithm allowlist (RS256)alg comes from the sender. Honouring it is how none and HMAC-confusion work
Key by kidA token signed by a key that has been rotated out but is still published
SignatureThe obvious one — and the return value is checked, not ignored
issA token from something that is not Nafath
audA genuinely Elm-signed token minted for a different service provider
exp / nbfReplay. 30 seconds of clock skew, and no more
transIdA 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.

SituationResponse
Verified completion, rejection or expiry200, attempt settled
WAITING200, nothing settled — nothing was decided
Token fails verification422, and the attempt records why
Unrecognised status200, recorded as a failure naming the value
Already settled (redelivery)200, nothing changed
Unknown requestId404
Cannot reach Nafath for keys503, 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 claim

SigningCoverage.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:

MeaningWhose job
Not builtNo integration existsEngineering
Built, not configuredCredentials missingDeployment
Signing in legacy systemContracts exist, produced elsewhereMigration

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.