Offline sign
A user can approve even when the phone has no internet: your page shows a QR, the phone reads it offline, shows your brand and the transaction details, asks for Face ID and produces a 7-character code. The user types it into your page and Touchque verifies it. The QR content is encrypted for the user’s phone only: any other QR reader or JWT decoder sees just ciphertext. A code is bound to one challenge and works once.
POST/offline/challenge
| Endpoint | Body | Result |
|---|---|---|
POST /offline/challenge | externalUsername, type, details?, clientIp?, userAgent?, ttlSeconds? | QR text (qr), ready QR image (qrDataUrl), challengeId, expiry, totpAvailable |
POST /offline/verify | challengeId, code | { approved: true, externalUsername, type } — 401 + attemptsLeft on a wrong code |
POST /offline/totp/verify | externalUsername, code, type?, clientIp? | Time-based code (lower assurance). 403 for critical actions |
Turn on “Offline sign (QR + code)” in the dashboard under Developers → App user policy → Offline (off by default). Critical actions also need “Allow offline sign for critical actions” and always require transaction details. The time-based code is its own switch on the same tab, “Offline time-based code (lower assurance)”, and needs offline sign on.
Limits: the code is made without the phone’s live risk check, and a phishing page can show a real QR unchanged (the phone shows the brand, location and device). It is MAC-based: only Touchque can verify a code. A challenge locks after 5 wrong tries, and there is an hourly failed-attempt limit per user.
Offline time-based code (optional)
When “Offline time-based code” is on in the policy, the app shows a 7-character code that rotates every 30 seconds, no camera or QR needed. It carries no transaction details and can be relayed within its window, so it is lower assurance and never accepted for critical actions. Each code works once.
Challenge + verify
const { Touchque } = require('@touchque/node');
const tq = new Touchque({ apiKey: 'tq_abc123', apiSecret: 'YOUR_API_SECRET' });
// 1) Create a challenge when the user chooses offline verification
const challenge = await tq.offline.challenge({
externalUsername: 'user@yourcompany.com',
type: 'WITHDRAW', // Action Type from your Dashboard
details: { Amount: '5.000 TRY', To: 'TR12 0006 …' },
clientIp: req.ip,
userAgent: req.get('user-agent'),
});
// Show it on your page:
// <img src={challenge.qrDataUrl} /> (or draw challenge.qr with your own QR renderer)
// 2) Verify the 7-character code the user types from the phone
const result = await tq.offline.verify({ challengeId: challenge.challengeId, code: 'K7M-2QXP' });
if (result.approved) { /* complete the action */ }
else { /* result.reason, result.attemptsLeft */ }challenge = tq.offline.challenge('user@yourcompany.com', type='WITHDRAW',
details={'Amount': '5.000 TRY'}, client_ip=ip, user_agent=ua)
# show challenge['qrDataUrl']
result = tq.offline.verify(challenge['challengeId'], 'K7M-2QXP')
if result['approved']:
... # complete the action; else result['reason'], result.get('attemptsLeft')$challenge = $tq->offline->challenge('user@yourcompany.com', 'WITHDRAW', ['Amount' => '5.000 TRY'], $request->ip(), $request->userAgent());
// show $challenge['qrDataUrl']
$result = $tq->offline->verify($challenge['challengeId'], 'K7M-2QXP');
if ($result['approved']) { /* complete the action */ }challenge, err := tq.Offline.Challenge(touchque.OfflineChallengeOptions{
ExternalUsername: "user@yourcompany.com", Type: "WITHDRAW",
Details: []touchque.LoginDetail{{Label: "Amount", Value: "5.000 TRY"}},
ClientIP: ip, UserAgent: r.UserAgent(),
})
// show challenge.QRDataURL
result, err := tq.Offline.Verify(challenge.ChallengeID, "K7M-2QXP")
if err == nil && result.Approved { /* complete the action */ } // result.Reason, result.AttemptsLeftvar challenge = await tq.Offline.ChallengeAsync(new OfflineChallengeOptions
{
ExternalUsername = "user@yourcompany.com", Type = "WITHDRAW",
Details = [new LoginDetail("Amount", "5.000 TRY")], ClientIp = ip, UserAgent = userAgent,
});
// show challenge.QrDataUrl
var result = await tq.Offline.VerifyAsync(challenge.ChallengeId, "K7M-2QXP");
if (result.Approved) { /* complete the action */ } // result.Reason, result.AttemptsLeftJsonNode challenge = tq.offline().challenge(new Offline.ChallengeOptions("user@yourcompany.com")
.type("WITHDRAW").details(List.of(LoginDetail.of("Amount", "5.000 TRY"))).clientIp(ip).userAgent(userAgent));
// show challenge.path("qrDataUrl")
JsonNode result = tq.offline().verify(challenge.path("challengeId").asText(), "K7M-2QXP");
if (result.path("approved").asBoolean()) { /* complete the action */ }challenge = tq.offline.challenge("user@yourcompany.com", type: "WITHDRAW",
details: { "Amount" => "5.000 TRY" }, client_ip: request.remote_ip, user_agent: request.user_agent)
# show challenge["qrDataUrl"]
result = tq.offline.verify(challenge["challengeId"], "K7M-2QXP")
complete_action if result["approved"] # else result["reason"], result["attemptsLeft"]# TS, NONCE and SIG: see Authentication (each request with its own nonce and signature)
curl -X POST https://api.touchque.com/offline/challenge \
-H "x-api-key: tq_abc123" -H "x-timestamp: $TS" -H "x-nonce: $NONCE" -H "x-signature: $SIG" \
-H "Content-Type: application/json" \
-d '{"externalUsername":"user@yourcompany.com","type":"WITHDRAW","details":{"Amount":"5.000 TRY"}}'
curl -X POST https://api.touchque.com/offline/verify \
-H "x-api-key: tq_abc123" -H "x-timestamp: $TS" -H "x-nonce: $NONCE" -H "x-signature: $SIG" \
-H "Content-Type: application/json" \
-d '{"challengeId":"<challengeId>","code":"K7M-2QXP"}'Response 200
{
"challengeId": "c0ffee…",
"qr": "TQ2.k1.pKq3…",
"qrDataUrl": "data:image/png;base64,…",
"expiresAt": "2026-09-26T12:02:00.000Z",
"expiresInSeconds": 120,
"totpAvailable": false
}Time-based code
// Lower assurance: no QR, no transaction details. Refused for critical actions.
const result = await tq.offline.verifyTotp({
externalUsername: 'user@yourcompany.com',
code: 'K7M-2QXP',
type: 'LOGIN',
clientIp: req.ip,
});
// result = { approved: true } | { approved: false, reason: 'invalid_code' | 'offline_totp_disabled' | … }result = tq.offline.verify_totp('user@yourcompany.com', 'K7M-2QXP', type='LOGIN', client_ip=ip)$result = $tq->offline->verifyTotp('user@yourcompany.com', 'K7M-2QXP', 'LOGIN', $request->ip());result, err := tq.Offline.VerifyTotp("user@yourcompany.com", "K7M-2QXP", "LOGIN", ip)var result = await tq.Offline.VerifyTotpAsync("user@yourcompany.com", "K7M-2QXP", "LOGIN", ip);JsonNode result = tq.offline().verifyTotp("user@yourcompany.com", "K7M-2QXP", "LOGIN", ip, null);result = tq.offline.verify_totp("user@yourcompany.com", "K7M-2QXP", type: "LOGIN", client_ip: request.remote_ip)