PQC Imza integration guide (pqc-connect v2)

What it is

pqc-connect lets a company ask a PQC Imza account holder to sign something, on the phone. The request appears in the holder's PQC Imza phone app, the holder signs it there with a post-quantum signature made by the phone's key, and the company gets the signature back. v2 is the version in which the phone builds what it shows from the bytes it signs, and signs under a context of its own. That is what lets a company check a signature without taking PQC Imza's server at its word for one statement, under the conditions in section 7.

Status

v2 has been offered on production since 2026-10-31. Signing needs PQC Imza app 1.8.0, on Google Play to everyone since 2026-11-01 and on the App Store since 2026-11-02; an older app is told 409 update_required (10.1). One client has used it: VirtualAI University, PQC Imza's own test client, made two live test signatures, on 2026-10-31 and 2026-11-05, both on the phone of PQC Imza's owner. The first was made with 1.8.0 on that phone before the release had reached everyone. As of 2026-11-06 no other client has. Two AI reviewer agents of VirtualAI University, which has the same owner as PQC Imza, one for application security and one for cryptography, reviewed the pqc-connect v2 contract on 2026-11-05 and this guide on 2026-11-06. Each time both asked for changes first and passed the revised text. This is academic review by AI agents: it is not an independent third-party audit, and it is not a certification.

Base URLs and terms

Base URL for signature requests: https://pqc-imza.com/api/connect/v2. Connect links, connections and /client stay under https://pqc-imza.com/api/connect/v1. Every request and response body is JSON (Content-Type: application/json), UTF-8.

Route Section
POST /api/connect/v2/signature-requests 3
GET /api/connect/v2/signature-requests/{id} 4.1
GET /api/connect/v2/signature-requests?status=pending&limit=50&before=sr_... 4.1
POST /api/connect/v2/signature-requests/{id}/cancel 4.1
POST /api/connect/v2/signature-requests/{id}/simulate (test mode only) 12
GET /api/connect/v1/client 4.1
POST /api/connect/v1/connect-links 2.3
GET /api/connect/v1/connect-links/{id} 2.3
POST /api/connect/v1/connect-links/{id}/complete 2.3
POST /api/connect/v1/connect-links/{id}/simulate (test mode only) 12
GET /api/connect/v1/connections/{id} 2.4
GET /api/connect/v1/connections?external_user_id=... 2.4
POST /api/connect/v1/connections/{id}/revoke 2.4

Words used here:

  • client: a service that integrates with pqc-connect. It belongs to an organisation.
  • signer: a PQC Imza account holder who is asked to sign.
  • live and test: the two modes. Live requests reach real phones. Test requests never do (section 12).

An older version, v1, exists for earlier clients. New clients should not use it. In v1 the phone signs bytes the server chooses, so a compromised PQC Imza server could obtain a valid signature the signer never saw. If you run a v1 client, contact PQC Imza about moving to v2.


Quick start: the seven rules

  1. Compute the digest of the key you pin, yourself, from the 1,312 bytes, and have the signer compare all 64 characters on their phone. A pin that was not compared means the server can sign for that user. Every step below passes on a signature the server made alone with its own key. Keep with each pin the digest the signer compared: a pin whose digest is not that one has not been compared. Choose the pin by your own record of whom you asked, never by a field of a response.
  2. Keep your client's name as a constant: it cannot change after registration (1.2). Keep your organisation's name too, and record with each request the name it was created with: an administrator can rename an organisation at your request, and a request keeps the name it had. Rebuild from that record. Never read either name from a response.
  3. Send client_nonce and expires_at on every request. Both are required, and both are yours. Draw them once for each request, before its first attempt, and send them unchanged on every retry (section 3).
  4. Rebuild the whole message from values you hold. From the server you take exactly two values, id and created_at, from the creation response or, if you never received it, from an object whose client_nonce is yours and which passes section 3's comparison. Neither value means anything to a signer.
  5. The context is a constant in your code: pqc-connect/v2. Never read it from a response. If signature.context is present and differs, refuse.
  6. Stop on any disagreement, before you look at a signature. verify-connect-v2-signature.cjs does rules 4 to 6, and gives you the digest for rule 1.
  7. Judge time by your own clock. Refuse a signature that reached you after expires_at, or after the stamp you recorded for your own cancel (section 6, step 9). The script does not do this: it does not know when the signature reached you.

1. Register a client and get keys

A client is one service of an organisation. It has:

Field What it is
id Public identifier, cli_ followed by 24 letters and digits.
name The name shown to signers on the consent page and on the phone. 2 to 80 characters.
organisation The organisation it belongs to, for life. Its owners and admins manage it.
registered by The PQC Imza account that registered it. A record only: it decides nothing.
status active or disabled.
live access approved or awaiting approval (1.2).
webhook URL Where results are sent (section 9). https:// only.
webhook secret whsec_ followed by 40 letters and digits. Stored encrypted, because the server needs it to sign each webhook.

1.1 Registering a client

An owner or admin of the organisation opens its developer area (1.5), enters the service's name and the webhook URL, and gets a client. A PQC Imza administrator can also register one. Both ways produce the same client. An organisation can register at most 5 clients on the website; an administrator can add more.

On creation the client gets one test key, one live key and a webhook secret. All three are shown once, on the page that created them, with a copy button and the warning that they will not be shown again. After that the server cannot show them: the keys are stored as a SHA-256 hash only, and the developer area never displays the stored webhook secret.

It carries its organisation's organization_id (3.8), which is not a secret and stays readable.

1.2 Live access needs an administrator's approval; test access does not

A new client can use its test key at once. Its live key is refused with 403 live_not_approved until a PQC Imza administrator approves the client.

The reason: the client's name is what a signer sees on the consent page and on the phone as the party asking for a signature. Anyone can type "PQC Imza Security", a bank's name, or a university's name into a registration form. Consent (2.1) stops a client reaching people who have not accepted it, but the consent page itself would carry that name, and a convincing consent link is easy to send. Approval is where a person checks the name against the company before any phone sees it. For the same reason the name cannot be changed after registration: what a signer accepted stays what they see. A company that changes its name registers a new client.

The organisation's name is reviewed too, and first, because it is signed and shown to the same people: a client's live key works only with both reviewed, and a client cannot be approved until its organisation's name is.

The consent page (2.1) also refuses clients that are not approved, so an unreviewed name is never put in front of a signer.

1.3 Disabling

A PQC Imza administrator can disable a client. While disabled:

  • every API call with any of its keys gets 403 client_disabled (or 429 rate_limited past the key's limit, 11.1);
  • its pending live requests are cancelled at once (canceled, reason client_disabled), so they leave the signer's phone;
  • no webhooks are sent to it (deliveries are recorded as skipped);
  • its consent page refuses new consents.

Re-enabling restores API access. Cancelled requests stay cancelled.

A client also answers 403 client_disabled while its organisation is disabled, suspended or closed.

1.4 API keys

pqcc_live_<40 letters and digits>
pqcc_test_<40 letters and digits>
  • The mode is part of the key. A test key can only create and read test requests; a live key only live ones. A request made in one mode is 404 not_found to a key of the other.
  • About 238 bits of randomness (40 characters from 62), from a cryptographic random source.
  • Stored as sha256(key) in hex, plus a visible prefix (pqcc_live_ and the next 6 characters) so a key can be recognised in lists and logs. The full key is never stored or logged.
  • Sent as Authorization: Bearer <key>.
  • A missing, malformed, unknown or revoked key all get the same answer, 401 invalid_api_key.
  • Rotation: issue a new key in the developer area, deploy it, then revoke the old one. Both work in between. At most 5 unrevoked keys per mode.
  • Revocation takes effect on the next request: the key is checked against the database on every call, nothing is cached.

1.5 The developer area

https://pqc-imza.com/{locale}/organizations/{uuid}/developers, signed in, in every language the site has.

  • Register a client: service name and webhook URL. The test key, the live key and the webhook secret are shown once, with copy buttons and the warning.
  • Keys: each key's prefix, mode, when it was made and last used; issue a new key (shown once), revoke a key.
  • Webhook: change the URL, rotate the secret (shown once), send a ping.
  • Requests: the latest requests in both modes; each one's page has its status, the signed message and signature when there is one, and the delivery log of every webhook attempt.
  • Consent link to give to signers, and the client's audit events.

Only the organisation's owners and admins see or change its clients. Anyone else, a signer of the same organisation included, gets 404 for every page and action, the same as for a client that does not exist. So does a real client under another organisation's address.


2. Connect your users

A client may send live requests only to an account that has accepted that client. Without this, any company with a key could push requests to any account's phone. There are two ways in: the consent page (2.1), for clients that work by email address, and connect links (2.3), for clients that have their own users.

2.1 Consent

  • The client sends the signer to its consent link: https://pqc-imza.com/{locale}/connect/authorize/{client_id} (the developer area shows it).
  • The account's email must be confirmed. Signing in with Google or Apple confirms it. An address that was typed in and never confirmed proves nothing, and such an account can neither accept a client nor press Allow, and no request can reach it. Otherwise anyone could claim an address and sign for it.
  • The signer signs in (Google or Apple, as usual) and sees the client's name, what accepting allows (sending signature requests to the phone), and what it does not (it cannot sign for them; every request needs their fingerprint, face or the phone's screen lock). They press Allow or Don't allow.
  • Accepting is recorded once, per account and client. The signer can revoke it at any time on https://pqc-imza.com/{locale}/connect (Connected services).
  • Revoking cancels that client's pending requests to that account (canceled, reason consent_revoked) and they leave the phone. Every step on the phone also checks that consent still stands, so a withdrawal that races a create cannot leave a signable request behind.
  • Closing an account ends its consents, and cancels its pending requests with reason account_closed. The signer_email on its requests becomes one fixed value, closed@closed.invalid, in the webhook of that cancellation too. A request or a consent that lands in the moment the account closes is ended the same way by a scheduled job, with the same reason and the same webhook. That job runs every minute, so this is within a minute while the scheduler runs. If the scheduler stops, nothing bounds that minute, and the job catches up when it starts again. Nobody can sign such a request in the meantime: a closed account's sign-in is refused, whether it comes by a token, a session or a remember cookie, and a signing session opened before the closure stops working.

2.2 No account discovery

A request to an email address that has no account, to an account whose address was never confirmed, to an account that has not accepted this client (or revoked it), and to a closed account all get exactly the same response:

422 {"code": "signer_unavailable", "error": "..."}

The lookup is one query (consents of this client, joined to accounts with that confirmed email), so the cases take the same path. A client therefore learns only what it already knows: that a person it sent to its consent link accepted it.

2.3 Connect links: tying your user to an account

A client that has its own users can tie one of them to a PQC Imza account, and receive that account's key to pin, before it asks for any signature. The consent page of 2.1 stays, for clients that work by email address.

1. Make a link.

POST /api/connect/v1/connect-links
Authorization: Bearer pqcc_live_...
Content-Type: application/json

{
  "external_user_id": "user-4821",
  "state": "k3J9-a-random-value-kept-in-this-users-session",
  "return_url": "https://example.com/pqc/return"
}
Field Rules
external_user_id Required. Your own id for this user, 1 to 128 printable ASCII characters, no spaces. Opaque here: compared byte for byte, never interpreted, and never shown to anyone.
state Required. 16 to 256 printable ASCII characters, no spaces. A fresh random value that you store in this user's signed-in session.
return_url Required. Where the browser comes back to. https://, the rules of content_url (3.1), and no fragment. It cannot be changed afterwards.

The answer is 201, and its body is flat. There is no wrapper: url sits at the top level beside the fields of the link, so read id and url from the top of the body, not from a connect_link key. These are all the fields:

{
  "url": "https://pqc-imza.com/connect/link/<48 characters>",
  "object": "connect_link",
  "id": "lnk_7hK2mZ9qL4xR8tN1vB5cW3yD",
  "mode": "live",
  "status": "pending",
  "external_user_id": "user-4821",
  "state": "k3J9-a-random-value-kept-in-this-users-session",
  "return_url": "https://example.com/pqc/return",
  "created_at": "2026-10-30T12:00:00Z",
  "expires_at": "2026-10-30T12:15:00Z",
  "connection": null
}

object is always connect_link. GET /connect-links/{id} returns the same body without url. The URL, https://pqc-imza.com/connect/link/<48 characters>, is shown this once, and only its hash is kept. A link lasts 15 minutes and can be answered once.

2. Send your signed-in user there, by redirect, at once. The first browser to open the address holds the link, before anyone has signed in, and every other browser is closed out. So do not email it, post it in a chat or show it to anyone: a mail scanner or a preview that fetches it first closes it for the person, who then has to start again.

3. The person answers. They sign in to PQC Imza as usual and see the company's name and service ID, which PQC Imza account is about to answer, what allowing means, and a warning to go on only if they started this themselves on that company's site. They press Allow or Don't allow. Your external_user_id is not shown.

Opening the link answers nothing. The first signed-in account to open it in that browser holds it; for any other account it is closed. An account whose email is not confirmed, or that has no proven key, cannot press Allow. The closed page is one page, with one status, for a link that is unknown, used, expired, for a test, or held by someone else.

4. The browser comes back. The person presses a link back to you:

https://example.com/pqc/return?connect_link=lnk_...&state=...&result=allowed&code=...

After Don't allow, result is denied, there is no code, and you also get a connect_link.denied webhook.

5. Check, then complete. This check is yours, and it is the one that matters. Pressing Allow attaches nothing. Before you complete, confirm that:

  • this browser's session is signed in to your site as the user you made the link for;
  • state equals the value you stored in that session;
  • connect_link is the link you made.

Then, within 10 minutes:

POST /api/connect/v1/connect-links/{id}/complete

{"code": "..."}

Why. An attacker with an account at your company could start a link for their own user and send it to someone else, who presses Allow. That person's browser then comes back to you with no session belonging to the attacker, and your check stops it: you do not complete, the link runs out, nothing is attached, and no key leaves. PQC Imza cannot make this check for you, because only you know who that browser is signed in as.

If you skip the check, the result is bounded, not safe. No app code sends the key anywhere, and nothing is signed without the victim's fingerprint, face or phone screen lock. On an iPhone the key can travel in an encrypted backup. These do happen:

  • the victim's public key is attached to the attacker's user;
  • consent to your company is recorded, which they did not intend;
  • up to 20 pending requests can reach their phone;
  • anything they then sign is attributed in your system to the attacker's user.

They can undo it by withdrawing, and account_already_connected refuses it if they are already connected to you. It does not stay contained in your records until they notice.

6. The result. complete returns the connection. The same object comes in a signed connection.connected webhook, from GET /connect-links/{id} under connection, from GET /connections/{id}, and from GET /connections?external_user_id=user-4821, which returns a list of at most one. There is no way to list your connections, and no call reaches another client's. The url in step 1 and qr_content use PQC Imza's fixed public address, whatever host your calls reach.

{
  "object": "connection",
  "id": "conn_7hK2mZ9qL4xR8tN1vB5cW3yD",
  "mode": "live",
  "status": "active",
  "external_user_id": "user-4821",
  "signer_address": "0x5c1f0e4b9a3d2c7e8f6a1b0d9c8e7f6a5b4c3d2e",
  "public_key_base64": "...",
  "connected_at": "2026-10-30T12:00:00Z",
  "revoked_at": null,
  "revoked_by": null
}

Those are all the fields. complete answers 422 invalid_code for a wrong code, and the right one still works. It answers 409 with:

code Meaning
link_closed Expired, answered and denied, already completed, never allowed; the account has since closed or lost its key; or the person withdrew consent after pressing Allow. Their last word stands. Make a new link.
external_user_id_taken That user is connected to another account. A user id never moves by itself: revoke the connection first.
account_already_connected This account is connected to you under another external_user_id. One account is one of your users, and the database enforces it.

A link belongs to its client and mode. From another client, or with the other mode's key, it is 404 link_not_found, the same as one that does not exist.

The safety controls, in one place.

Threat Control
A person is sent a link made for someone else's user Allow attaches nothing; the client's check in step 5 ends it
A forged form on another site A CSRF token, and a one-time value on the page this session was shown
A session planted before sign-in Signing in replaces the session
A browser signed in to an attacker's account by a cross-site form PQC Imza's sign-in needs a CSRF token from a browser
An address someone typed and never proved A confirmed email is needed to accept, to press Allow, and to be reached
Signed in to the wrong PQC Imza account The page names the account about to answer
A link seen by someone else The first browser holds it from the moment it opens the address, signed in or not, so a copy is second even while the person is signing in. The code goes only to return_url. The page sends no-store and asks for no-referrer; a cross-origin referrer carries no path either way, so nothing here relies on that header. The token is in the address, so web server logs hold it for the 15 minutes it can work.
A link or code used twice Each moves its status once; a used link and an expired one get one page
A return address changed on the way It is fixed at creation; nothing in the person's request can change it
Another client's link Found only within the calling client and mode
Allow, then withdrawal, then complete Refused: withdrawing and completing take one lock, and the withdrawal stands

What this does not defend. The check in step 5 stops a victim's account landing on an attacker's user. It cannot stop the reverse, an attacker's account on a victim's user, if the attacker gets the link first:

  • The attacker must have the link's address before the victim's browser opens it. A redirect normally rules that out, which is why step 2 says to redirect and never to send it.
  • They open it, press Allow, and send the return address to the victim, who must click it while signed in to your site. The right session, state and link then all arrive, and every check passes. PQC Imza cannot see this from the server, and neither can you.
  • Comparing the key on the phone, by the digest you computed (2.5), ends it, because the digest will not be the victim's. Until that comparison is made, a connection is as strong as the channel the link travelled through.
  • An operator of PQC Imza's web server, who can read logs, sees a link's token for its 15 minutes. That operator is already trusted with the key this link delivers.

2.4 Connections

What a connection means. The company stays the authority on who its people are. A connection says that a person with this account pressed Allow on a link made for that user, and that the company then saw the right browser come back. It does not mean PQC Imza vouches for a name, a role or a membership. PQC Imza keeps no member directory for a client. No name, national ID or email address goes to a client through a connection, and consent means only that the person accepted the company.

After a connection. Name the signer by external_user_id instead of signer_email when creating a request (section 3). It works only for an active connection of yours in that mode, on an account that is still open and whose consent stands. Every other case gets the same 422 signer_unavailable as 2.2, so it reveals nothing you did not know. The request object then has signer_email: null: the account's email is not yours to learn this way.

When a connection ends. A signer who withdraws consent under Connected services, or closes their account, ends it (revoked_by: "signer"), and pending requests are cancelled as 2.1 says. You can end one yourself with POST /connections/{id}/revoke (revoked_by: "client"), for instance one you got wrong; the consent itself stays. Either way you get a connection.revoked webhook. A revoked user id can connect again through a new link, and keeps its connection id.

When the key changes. A new phone means a new key. You get a connection.key_changed webhook with the new signer_address and key, and the connection's GET shows them. public_key_base64 is null if the account has not yet proved the new key. A null never clears your old pin.

Do not move your pin on this webhook. It is PQC Imza's word, and a hostile server is what a pin protects you from. Requests go on opening with the new key, and signatures by it fail step 3 of section 6 against your old pin. That is correct: treat them as not yet accepted, confirm the new key as section 8 says, then change the pin. A new key needs the same comparison as the first one. A person who controls the account can always press Allow again, so reconnecting does not prove the key either.

2.5 Comparing the key digest

The client gets the full key before the first signature, with nothing asked of the signer. That closes the gap of a signer having no way to hand over their key. It does not change who you are trusting:

  • The key comes from PQC Imza's server. A hostile server could return a key it holds itself, and every signature afterwards would verify against your pin. A pin taken this way is trust in the server until you check it some other way. Say so to anyone who relies on it.
  • Do this much in any case: derive the address from the key (section 6, step 3) and compare it with signer_address. That catches an error. It does not catch a lie, because both values came from one place.
  • What ends that trust is a comparison on the signer's phone, or another channel. The phone computes its key's digest from its own stored key. You compute yours from the 1,312 bytes you are about to pin, in your own code, and show that on your page. Have the signer compare all 64 characters with the phone, or have them copy it to you so a machine compares it. The digest is the SHA-256 of the 1,312 decoded bytes, in lower-case hex with no 0x. Never display a digest the server sent you. A hostile server can return a key it holds beside the digest of the signer's real key, and the comparison would pass on the substituted key. That is why the connection object has no digest field. People compare the start and the end, and ten hex characters in all, such as five at each end, is within a hostile server's reach, at 2^40 key generations. The key itself is Base64 here and hex on the phone, so compare the digest, not the key's text.
  • A website is not a comparison. PQC Imza's profile page is the server. A comparison there proves nothing against the server.
  • This covers every statement PQC Imza's server makes about the key: connection.connected and GET /connections as much as a webhook. Only the first pin may come from them, and keep it marked as not compared until it has been.
  • Silence proves nothing. A hostile server can simply send no webhook, so no webhook does not mean the key is still good. What tells you a key changed is step 3 of section 6 failing against your pin. Nothing tells you a key was stolen, except the signer (section 8).

A connection object has no public_key_sha256. A field a client must never use should not be there: a hostile server could return a key it holds in public_key_base64, and the digest of the signer's real key beside it. The address check passes, the digest matches the phone's, all 64 characters, the signer says so, and the client has pinned the server's key. Compute the digest from the bytes you are about to pin, and show that.

The key in a connection is still the server's word until it is compared. What ends that is the comparison above, with your own digest. The phone's Show full key computes its digest from its own storage, so it equals yours exactly when you hold this phone's key. Show full key shows the key's digest in groups of four characters. Show yours the same way, so the signer compares like with like. The verifier script's digest step (6.2) computes the digest from the 1,312 decoded bytes.

Do not build on a signed connection. A hostile server signs that statement itself with a key it holds, and a signature by a key proves only that someone holds that key. Only something that does not pass through PQC Imza can end first-use trust.


3. Create a request

3.1 The request

POST /api/connect/v2/signature-requests
Authorization: Bearer pqcc_live_...
Idempotency-Key: 4f1c2a9e-correction-14
Content-Type: application/json

{
  "external_user_id": "user-4821",
  "organization_id": "00000000-0000-0000-0000-000000000000",
  "title": "Correction notice 14",
  "content_sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
  "content_url": "https://example.com/notices/14.md",
  "reference": "notice-14",
  "client_nonce": "7bT2-kQ9x_LmZ4pa",
  "expires_at": "2026-11-06T12:00:00Z"
}
Field Rules
signer_email The email of the signer's PQC Imza account (the Google or Apple address they sign in with). Compared without regard to case. Required unless external_user_id is sent; sending both is refused.
external_user_id Your own user id, for a user connected through a link (2.3). Required unless signer_email is sent.
organization_id Required, always. The nil UUID for a personal signature, or your organisation's ID. See 3.8.
title Required. 1 to 200 characters. See 3.2.
content_sha256 Required. The SHA-256 of the content, 64 hex characters. Upper case is accepted and stored lower case.
content_url Required. Where the signer can read the content. https://, at most 2048 characters of printable ASCII, no spaces, no backslash, no user name or password (https://good.example@evil.example/ reads as the wrong host). Stored exactly as sent.
reference Optional. The client's own identifier, at most 128 characters, under 3.2's rule for text. Returned, never shown to the signer, not signed.
client_nonce Required. 16 to 64 characters from A-Z a-z 0-9 _ -. Sixteen characters is about 96 bits only if they are random and new, so draw it from a cryptographic random source once for each request, before its first attempt, store it with your record, and send the same one on every retry. Refuse a nonce that is already on another of your records. It is part of the idempotency comparison (3.4).
expires_at Required, and in exactly this form: YYYY-MM-DDTHH:MM:SSZ, in UTC. An offset is refused, so that the line you rebuild is the string you sent. Between 10 minutes and 30 days from the time a request is created. Fix it once for each request, as the nonce.
Idempotency-Key header Required. 1 to 128 characters from A-Z a-z 0-9 - _ . :.

3.2 Text that is shown and signed

title and the client name are shown to the signer and are part of the signed message, so they are refused (422 validation_failed) if they contain:

  • a character of Unicode category Cc, Cf, Zl, Zp, Co, Cs or Cn: every control and format character (line breaks, ALM, LRM, RLM and all other bidirectional controls, the soft hyphen, the zero width space, the byte order mark, tag characters), line and paragraph separators, private-use and unassigned code points;
  • a default-ignorable or blank character outside those categories. Known examples, not a closed list: U+034F, U+115F, U+1160, U+17B4, U+17B5, U+180B to U+180F, U+2800, U+3164, the variation selectors, U+FFA0, U+FFFC, U+16FE4, U+1D159, U+1D173 to U+1D17A;
  • four or more spaces in a row, or five or more combining marks in a row.

ZWNJ and ZWJ (U+200C, U+200D) are allowed only between two characters of a script that needs them to spell words: Arabic, Devanagari, Bengali, Gujarati, Gurmukhi, Kannada, Malayalam, Oriya, Sinhala, Tamil and Telugu. A value must also contain at least one visible character.

Direction. The order a signer reads depends on the whole value, not on any one character: Transfer ۔1000-2000, with an Arabic full stop, is stored as 1000-2000 and reads as 2000-1000. So a value holding any character from the default right-to-left blocks (U+0590 to U+08FF, U+FB1D to U+FDFF, U+FE70 to U+FEFC, U+10800 to U+10FFF, U+1E800 to U+1EFFF) must be right to left throughout. It must hold a right-to-left letter. Beside right-to-left characters it may hold only ASCII digits, spaces, basic punctuation, currency signs and combining diacritics, which is a list of what is allowed because no list of left-to-right characters is complete. And it must not hold two numbers with no letter between them, where U+0640 does not count as a letter. إشعار التصحيح 14 passes; Transfer ب1000 and تحويل 1000-2000 do not.

A value that starts or ends with a space of any kind is refused, not trimmed. The API runs no trimming or empty-string middleware: the bytes the client sent are the bytes that are shown and signed, or the request is refused.

This does not stop look-alike letters, such as a Cyrillic а in a Latin word. In a title that changes nothing, since a client can write any title it likes. In a client name a person is the control: the administrator's page shows the alphabets in each name and flags a mix, a letter outside the alphabets it lists, and any fullwidth or styled form.

3.3 Compare before you store

After the response, compare before you store. client_id, client_name, organization_id, organization_name, title, content_sha256 (in lower case), content_url, expires_at and client_nonce must each equal what you sent or hold, and so must external_user_id when you named the signer that way. signer_email must too, lower-cased as ASCII, as the server stores it, while the request is pending: once the signer's account is closed, every read of any of its requests, signed or ended, a replay included, carries closed@closed.invalid (2.1). If any differs, stop: cancel the request, record why, and accept no signature on it. Against a hostile server the cancel proves nothing; against an honest one it takes the request off the phone, where the signer could otherwise sign something you will refuse. Keep id and created_at. A hostile server can write another name into the message and hand the same name back, and a client that rebuilds from the response gets matching bytes.

3.4 Idempotency

The Idempotency-Key is unique per client and mode, and kept for the life of the request.

A key your client and mode have used already is answered from that request before the attempt is counted against the create limit or validated, so a retry is answered by what the first attempt did (3.5). A replay creates nothing new, and no second request reaches the phone. A different body, a malformed one included, is 409 idempotency_conflict. A create of a new key that was refused (for example signer_unavailable) stores nothing, so the same key may be used again once the cause is fixed.

"Same body" means the same signer_email (case-insensitive), external_user_id, organization_id, title, content_sha256 (case-insensitive), content_url, reference, expires_at and client_nonce as sent (absent counts as a value of its own).

3.5 Retries

Retry under the same Idempotency-Key, with the same body, the same nonce and the same expires_at. A key your client and mode have used already is answered from that request before the attempt is counted or validated (3.4):

  • the same body is 200 with Idempotent-Replayed: true, and the request as it is now, which may by then be signed, declined, expired or cancelled. It gets section 3's comparison like a first answer;
  • any other body is 409 idempotency_conflict. The request exists, under the body you sent first.

These answers come before the key is looked up, so they say nothing about an earlier attempt: 401, 403, 429 for the key or the address, 422 idempotency_key_missing, and any answer with no code (section 11). Only 201, a replay or 409 speak for an earlier attempt, and no other answer means "not created". Once you hold an id for a record, refuse an answer that names another id or created_at for it. A request is created only while its expires_at is at least 10 minutes away, so leave a margin before a first attempt; a replay is not held to that. If no answer ever came, take id and created_at from a request you read later (a GET of the list, or a webhook) whose client_nonce is yours and which passes section 3's comparison. Those two values are the server's word wherever they come from.

3.6 The response

201 Created with a signature request object (section 4). In live mode the request is on the signer's phone from this moment: the next time the app's Sign tab loads, it is in the list. The object's qr_content is what a client's page shows as a QR code (10.4).

3.7 Open requests per signer

At most 20 pending live requests per client and signer. More gets 429 too_many_open_requests. A phone is not a queue.

3.8 Personal, or for an organisation

Every request says which, and the signer sees it before confirming.

organization_id is required on every request. There is no default. A missing value is refused, and it never reads as personal: an unstated capacity is a mistake, not a preference.

Value Means organization_name
00000000-0000-0000-0000-000000000000, the nil UUID Personal: the signer signs for themselves. The fixed word personal
Your organisation's organization_id The signer signs for your organisation. They must be a member of it. The organisation's name when the request was created

The exact accepted form. A JSON string of 36 characters, lower-case, with hyphens, equal to one of those two values. Everything else gets 422 validation_failed with an organization_id error: missing, null, an empty string, 0, "0", false, upper case, no hyphens, braces, surrounding space, another organisation's id, or a random UUID. The comparison is exact, so there is one way to write each value.

Your organisation ID. A random UUID (v4), given once when the organisation registers, which never changes. Read it from GET /api/connect/v1/client or the developer area. All the clients of one organisation carry the same one. It is not your client_id, and it is not an internal row number.

Membership. A live request with your organisation's id reaches only a signer who is an accepted member of that organisation, and who has accepted this client as well: one does not replace the other. Anyone else gets the same 422 signer_unavailable as 2.2. It is checked again whenever the phone is involved, and a signer who leaves or is removed has their pending requests for the organisation cancelled (membership_ended). Test mode looks up no account, so it checks no membership.

Why the word personal for the name, not an empty value. A value ending in a space is what a trimming layer alters, and the message builder refuses an empty line on purpose. So personal has a fixed word, and the two lines go together: the nil UUID with personal, or a real id with a real name. A verifier refuses any other pair. Neither a client nor an organisation can register under the word for personal in any of the app's ten languages, in any case or width, or with punctuation added. That stops the word and nothing more: a name in look-alike letters, or a longer phrase, is not stopped, and from app 1.8.0 the phone's layout is what tells the two states apart.

What it means. It is the capacity the signer accepted when they signed, and nothing more. It is not a role PQC Imza vouches for. organization_id with your id says that this person was shown your organisation's name and signed, and PQC Imza's server says they had accepted your organisation. It does not show they may act for you, and nothing in the signature proves the membership. Whether they may is yours to know, as 2.4 says of who your people are.

Where it appears. Two lines of the signed message (5.1), so a signature cannot be replayed under the other capacity. Also the request object, the webhook, the signer's page and the developer page. People always read "Personal" or the organisation's name, never the id alone.


4. The signature request object

4.1 Reading a request

GET /api/connect/v2/signature-requests/{id}
GET /api/connect/v2/signature-requests?status=pending&limit=50&before=sr_...
POST /api/connect/v2/signature-requests/{id}/cancel
GET /api/connect/v1/client
  • A request belongs to the client and mode that created it. Any other client, or a key of the other mode, gets 404 not_found, the same answer as for an identifier that does not exist.
  • The list is newest first, at most 100 per page; before takes the id of the last request on the previous page.
  • cancel works on a pending request only (409 not_pending otherwise). The request leaves the phone.
  • GET /client returns the client's id, name, organization_id, organization_name, status, whether live access is approved, and the mode of the key used.

4.2 The object

A signed request named by external_user_id:

{
  "object": "signature_request",
  "id": "sr_9QeV2mXo4b7TzL1wKc8yHn3d",
  "mode": "live",
  "status": "signed",
  "client_id": "cli_Fh3kL9pQ2rS7tV1wX5yZ8aB4",
  "client_name": "Example University",
  "reference": "notice-14",
  "signer_email": null,
  "external_user_id": "user-4821",
  "organization_id": "00000000-0000-0000-0000-000000000000",
  "organization_name": "personal",
  "title": "Correction notice 14",
  "content_sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
  "content_url": "https://example.com/notices/14.md",
  "message_version": 2,
  "client_nonce": "7bT2-kQ9x_LmZ4pa",
  "qr_content": null,
  "created_at": "2026-10-30T12:00:00Z",
  "expires_at": "2026-11-06T12:00:00Z",
  "signed_at": "2026-10-30T12:04:51Z",
  "declined_at": null,
  "canceled_at": null,
  "cancel_reason": null,
  "expired_at": null,
  "signature": {
    "algorithm": "ML-DSA-44",
    "signed_input": "sha256-of-message",
    "context": "pqc-connect/v2",
    "message": "pqc-connect/v2\nenvironment: live\n...",
    "message_base64": "cHFjLWNvbm5lY3QvdjIK...",
    "signature_base64": "...",
    "public_key_base64": "...",
    "signer_address": "0x5c1f0e4b9a3d2c7e8f6a1b0d9c8e7f6a5b4c3d2e",
    "classical": null
  }
}
  • message_version is 2, and client_nonce is the value you sent.
  • signer_email and external_user_id hold whichever named the signer, and the other is null.
  • signature is null until the request is signed. signature.message is always the exact message that was signed.
  • signature.context is "pqc-connect/v2". It comes from the record, for people reading it. Your code does not use it, except to refuse if it differs.
  • signature.classical is always null. v2 has no classical half.
  • qr_content is a string while the request is pending and null after that (10.4).
  • status is one of pending, signed, declined, expired, canceled. The last four are final.
  • cancel_reason is set only for canceled: client (you cancelled it), consent_revoked (the signer withdrew their consent), client_disabled (your client or its organisation stopped), membership_ended (the signer left or was removed from the organisation) or account_closed (the signer closed their account).
  • All times are UTC, YYYY-MM-DDTHH:MM:SSZ.

5. What is signed

5.1 The message

UTF-8, no byte order mark, fourteen lines joined by one line feed (0x0A), no final line feed:

pqc-connect/v2
environment: live
client-id: cli_Fh3kL9pQ2rS7tV1wX5yZ8aB4
client-name: Example University
request-id: sr_9QeV2mXo4b7TzL1wKc8yHn3d
created-at: 2026-10-30T12:00:00Z
expires-at: 2026-11-06T12:00:00Z
title: Correction notice 14
content-sha256: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
content-url: https://example.com/notices/14.md
organization-id: 00000000-0000-0000-0000-000000000000
organization-name: personal
client-nonce: 7bT2-kQ9x_LmZ4pa
signer-address: 0x5c1f0e4b9a3d2c7e8f6a1b0d9c8e7f6a5b4c3d2e

Each line is the key, a colon, one space, and the value. No value can contain a line feed (3.2 refuses it), so the lines cannot be confused with each other. What each line holds:

  • the first line is the literal pqc-connect/v2;
  • environment is live or test. A sandbox signature can never pass as a live one;
  • client-id is the client's id;
  • client-name is the client's name as it was when the request was created. It is what the signer was shown;
  • request-id is the request's id;
  • created-at is the request's created_at, UTC, YYYY-MM-DDTHH:MM:SSZ, and expires-at the request's expires_at, in the same form;
  • title and content-url are byte for byte as sent;
  • content-sha256 is the digest, 64 lower-case hex characters;
  • organization-id is the nil UUID for personal, or an organisation's UUID (3.8);
  • organization-name is the word personal for the nil UUID, otherwise the organisation's name as it was when the request was created;
  • client-nonce is the client_nonce you sent;
  • signer-address is the signer's key address, 0x and 40 lower-case hex characters (section 6, step 3).

signer-address is filled in when the signer opens the request on the phone, from the key their account has registered at that moment. That key must be proven: its public key is on file, which only a signed challenge or a verified signature puts there. If they register a different key before signing (a new phone), that signature is refused (key_changed) and opening the request again builds the message with the new address.

Where each value comes from, in a client's rebuild:

Line You take it from
environment A constant: live in production
client-id, client-name Your own configuration
request-id, created-at The creation response, or rule 4's other source. These two only.
expires-at, title, content-url, client-nonce What you sent
content-sha256 What you sent, in lower case
organization-id What you sent
organization-name Your own record: personal for the nil UUID, otherwise your organisation's name as recorded with this request (quick start, rule 2). Not your client's name, unless the two are one string
signer-address Derived from your pinned key (section 6, step 3)

5.2 The signature

ML-DSA-44 as in FIPS 204, the pure variant, Algorithm 2, with M = SHA-256(message), the 32 raw bytes, and the context string pqc-connect/v2, the ASCII of those fourteen characters. It is not HashML-DSA. Signing is hedged, so two signatures of one message differ, and both verify. signature_base64 decodes to 2,420 bytes and public_key_base64 to 1,312 bytes, standard Base64 with padding. message_base64 is the message bytes, for clients whose JSON tooling might alter a string.

5.3 Separation from the key's other messages

Each purpose has its own context, and the phone chooses it from the bytes it parsed. A signature under another context does not verify here. That includes an empty-context signature a hostile server collected from an old phone over these exact fourteen lines, and a new phone does not sign with its signing key under an empty context at all.


6. Verify a signature yourself

6.1 What you need, and the steps

What you need: your own record of the request (whom you named, what you sent, the organisation name recorded with it, and the id and created_at you kept), and the public key you pinned for the user you named. Take that pin from your own record, never by a field of the response, and keep beside it the digest the signer compared. A pin whose digest is not the compared one has not been compared (quick start, rule 1). The fourteen lines name no user of yours, so the pin is the only thing that ties a signature to the person you asked: a hostile server can deliver one user's request to another connected user, and a client that picks the pin by the response files the second user's signature as the first's.

The steps. Stop and record nothing if any step fails. Ignore status: it is a hint about when to look, not evidence. Strictly, for Base64, means the standard alphabet, with padding, no whitespace, and decoded bytes that re-encode to exactly the text received. PHP's strict mode skips whitespace, and Node's decoder skips characters it does not know, so check the re-encoding yourself.

  1. Find your record. id must be a request you created, and you must not already have recorded a sign-off for it. The pin you use is that record's user's.
  2. Check the response against what you hold, as in section 3. Do this before looking at the signature.
  3. Check the key. Decode public_key_base64 strictly. It must be exactly 1,312 bytes and equal your pinned key, byte for byte. Derive the address as 0x plus the last 20 bytes of Keccak-256 of those bytes, in lower case. This is Ethereum's Keccak-256, not SHA3-256.
  4. Rebuild all fourteen lines from 5.1's table, with that address on the last line.
  5. Check the context. If signature.context is present and is not pqc-connect/v2, refuse. Why: a hostile server can still have a 1.7.1 phone sign these fourteen lines under the empty context, report context: "", and a client that passes that field to its verifier gets a valid result.
  6. Compare the message. Decode message_base64 and compare it, byte for byte, with your rebuild.
  7. Decode the signature strictly. It must be exactly 2,420 bytes.
  8. Verify exactly one form. FIPS 204 ML-DSA.Verify (Algorithm 3) for ML-DSA-44, with your pinned key, the constant context pqc-connect/v2, and SHA-256(rebuilt message). Do not try another context, or none.
  9. Check the time, by your own clock. Refuse the signature if it reached you after expires_at, or after the stamp you recorded for your own cancel (section 8, condition 4). The server is trusted for timing (section 7) and can hold a genuine signature and deliver it late.
  10. Check the content. Compute SHA-256 of the content you hold, and compare it with the digest you sent.
  11. Record the request, the message bytes, the signature, the key, and your own time of receipt. Date the sign-off from your receipt, because a signature proves no time.

6.2 The verifier script

verify-connect-v2-signature.cjs does steps 2 to 8. It reads one JSON object from stdin, and it has no context argument and no way to read your name from a response. It does not do step 9, because it does not know when the signature reached you. It runs on Node 20.19 or later, which loads ES modules through require, with @noble/post-quantum 0.6.1 and either @noble/hashes 2.2.0 or 1.3.2, whichever resolves where you put it. On an older Node, or with neither package, it refuses everything and says why. Copy it into your own code at a version you have read, and never fetch it at run time: a copy you have read is your own code, which is what condition (c) of section 7 asks. It is published with this guide, with the known answer of 6.3 and a checksum file:

Its SHA-256 at this text:

dc79576d54518cbec68b47fac0a71982da33c59a984a69ef3d77b8df20ab1687  verify-connect-v2-signature.cjs

The script's comments were written against the contract this guide is drawn from, so the section numbers in them are the contract's: its 5.4 is section 6 of this guide.

Its input:

{
  "step": "signature",
  "own": {
    "environment": "live",
    "client_id": "cli_...",
    "client_name": "Example University",
    "organization_name": "personal"
  },
  "sent": {
    "organization_id": "00000000-0000-0000-0000-000000000000",
    "title": "Correction notice 14",
    "content_sha256": "9f86...",
    "content_url": "https://example.com/notices/14.md",
    "client_nonce": "7bT2-kQ9x_LmZ4pa",
    "expires_at": "2026-11-06T12:00:00Z"
  },
  "kept": { "id": "sr_...", "created_at": "2026-10-30T12:00:00Z" },
  "response": { "...": "the signature request object, whole" },
  "pinned_public_key": "<Base64, 1,312 bytes>"
}

It writes {"verified": true}, or {"verified": false, "reason": "..."}, and exits 0 only when verified. own.environment must be live or test, and own.organization_name is the name recorded with this request. A value holding a carriage return or a line feed is refused. Two other steps use the same script:

  • "step": "creation", with own, sent and response: section 3's comparison, and the form of the two values you keep: id is sr_ and 24 letters or digits, created_at is YYYY-MM-DDTHH:MM:SSZ. It writes {"verified": true, "keep": {"id": "...", "created_at": "..."}}.
  • "step": "digest", with public_key_base64: writes {"digest": "<64 hex>"}, computed from the 1,312 decoded bytes. It reads no other field, so passing it a whole connection object is safe.

6.3 A known answer

app-v2-signature.json, published with the script, is a signature by the app's own code over 5.1's example message, under pqc-connect/v2: the key, the message's SHA-256 (089fe67898f24c38836084f03ff3d6697636412f24759a5664a657739c1e5fdb), the context and the signature. Before you rely on your library, check that it verifies this under pqc-connect/v2 and refuses it under the empty context and over the raw message. A library that implements an earlier, pre-standard version of this scheme uses the same key and signature sizes, so the length checks pass and only verification fails; this known answer is how you find out. In this file the key and the signature are lower-case hex, not Base64: read as Base64, the key decodes to 1,968 bytes. The message has no final line feed, so if you copy the example in 5.1 from this page, remove the line break your copy adds at the end. The example's signer-address line is illustrative and is not that key's address, so the fixture tests the primitive, not the script.

The file's SHA-256:

76cbfacc40322aec977c0e56b6328f744e8d2c23035bc6ebea16c9d114080fbb  app-v2-signature.json

7. What a verified signature proves, and what it does not

This is the claim, and it may not be quoted without its conditions.

A company need not trust PQC Imza's server for one statement: the holder of this key signed these exact bytes. That holds only if the company (a) computed the digest of the key it pinned itself, and the signer compared all of it on their phone or through a channel that does not pass through PQC Imza; (b) rebuilt the whole message from values it holds itself, including its own name and organisation name; and (c) verified under a context that is a constant in its own code.

If the genuine app was the only software that used the key, the phone showed the signer the fields of 10.1 from those bytes before signing.

This covers pqc-connect v2 only. It does not cover documents, or any signature under the empty context.

What the argument rests on. The holder of the pinned key's secret, or software with access to it, signed these bytes, provided the signature scheme of 5.2 is UF-CMA secure, which has a reduction to conjectured lattice assumptions, and SHA-256 is collision resistant, which is conjectured. The implementations are merely unbroken.

What you still trust, by name.

  • PQC Imza as publisher of the app, for every release, including automatic updates. A signature does not say which build made it.
  • The phone's operating system, and a key held in software behind a prompt the app's own code runs. A prompt can be passed with the phone's screen lock as well as a fingerprint or a face. It is not "only a biometric". There is no attestation: a rooted phone or a malicious build looks the same to you.
  • PQC Imza's server for delivery, timing, status, declines and whether a request existed at all. It can withhold, delay or misreport anything. Silence proves nothing, and a decline carries no signature.
  • Yourself, for who the person is. It is a key. No name, national ID or membership reaches you. "On behalf of" means the signer read that line. Membership is not checked.

What a signature does not show.

  • That the signer read the content. They saw a title, a digest and a link. From app 1.8.0 they can have the phone fetch the link and compare it with the digest, on their own tap. That shows the link gave this phone a file with this digest, that once. Whoever controls the link's host can give a browser something else, so it does not show that what they read is that file. On a request your rebuild accepts, it shows your URL served your digest. On a request a hostile server forged it shows nothing, because that server chose both. It is optional, and you are not told.
  • When it was signed. It was made after your request carried the nonce, and before you received it. Date it from your receipt.
  • That the key is on one phone. On an iPhone it can travel in an encrypted backup, so a restored phone can hold a working copy while the original exists, and nothing tells you.
  • That the key is still in the signer's control (section 8).

Your company's name on the phone is authenticated by nothing the phone can check. Administrator approval and consent are controls on an honest server. A hostile server can show any company's name with a well-formed client-id, and can deliver a request from a company the signer never accepted, because the phone keeps no record of consent. Your own check stays safe: you have no matching record, so that signature fails. Your name does not: it can be used to phish your people, with the app's seal on the screen. Approved names protect you against other clients on an honest server, and against a hostile one they are nothing. When its own record can be read, the phone marks a first request from a company, and a known name under a new id; when it cannot, no mark appears. That is first use on the phone. It is not authentication, and it needs requests signed by you, which v2 does not have.


8. Keys over time

8.1 A signature proves no time

signed_at is outside the signature, and created-at is supplied by the server. "A signature after expiry is refused" is the server's promise. Date a sign-off by your own time of receipt, and refuse one that reaches you after expires_at by your own clock.

Freshness. client-nonce is what lets you show a signature is fresher than your own request.

8.2 Keep a validity record for each pin

Record the key, when and how it was confirmed, and when it stopped being valid. Accept a signature only from a key whose record was open at your time of receipt. There is one exception, in 8.3.

8.3 Pinning from a signature you hold: the one exception to an open record

8.2 accepts a signature only from a key whose record was open at your time of receipt, and a pin set from a signed request is set after receipt. That is allowed, for that one signature, within two limits and on four conditions.

  • Only a pqc-connect/v2 signature. An empty-context signature gets no benefit from this, whatever the comparison says: a 1.7.1 phone can sign a v1 request, update, keep its key and pass the comparison afterwards, and nothing parsed what it signed.
  • Only a key you hold no record for, for any of your users. It never reopens a closed record. A key you closed stays closed, whatever signature arrives under it.
  1. The time belongs to those bytes. Record your time of receipt when they arrive, and keep them. Never read the server again and stamp what it says now with the earlier time.
  2. All of section 6, then all of the digest, before you pin. Steps 1 to 8 of section 6 pass on this signature, with this key in the place of the pin. Then compare your own digest of it, all 64 characters, on the signer's phone or through a channel that does not pass through PQC Imza. A comparison that fails refuses the signature: record no sign-off, and do not pin. Keep what arrived, because a failed comparison is the one sign you get of a substituted key. Steps 9 to 11 of section 6 follow a comparison that passed.
  3. Record both dates: when it was received, and when the key was compared.
  4. Refuse it if it reached you after expires_at by your clock, or after you cancelled the request. A cancel starts at the stamp you record for it, and not when someone pressed a button. A signature that reached you before that stamp is not refused by this, even one made after the press, because a signature carries no time and the honest race looks the same. Say so where the person presses it.

The signature that revealed a new key may use this, on the same limits and conditions, because the argument does not depend on the signer being new. A renewed ID card makes a new key, so every client meets this. Your old pin fails at step 3; if the new key is one you hold no record for and it passes all four conditions, you may pin it and accept that signature, and you close the old record yourself.

No second signature is needed. A server signing with its own key can sign twice as easily, and a v2 signature that verifies under a compared pin means the same whichever came first. A connection delivers the key before any signature, so compare, pin, then sign is always open to a connected signer. Prefer it: the key from a connect link or a first signed request is the server's word either way, until compared.

8.4 A new key

The account registers it, and pqc-connect opens later requests with it. A client with a connection gets a connection.key_changed webhook as a hint (2.4); a client without one gets nothing, because key_changed is an internal refusal. Neither moves a pin.

A new key always needs a new comparison. A new phone, a reinstall or an ID card read again makes a new key. The app warns the signer before an ID card replaces a key, and that is all it does. v2 has no signed rotation statement in this release. So the old key says nothing about the new one, and your pin fails at step 3. That is correct. Treat it as not yet accepted until the exception above is met, compare again with your own digest, and never relax the check because it happens often: a client that does has given up condition (a). Renewing an ID card is ordinary, so expect this. Then add the new key to your record and close the old one.

8.5 A lost or compromised phone

Revocation is the signer's act and the client's record: the signer tells each client out of band, and the client closes the pin. PQC Imza refuses a key the account has replaced, but a client must not rely on that, and a lost phone's key stays valid for a pinned client until the client closes it.


9. Webhooks

When a request becomes signed, declined, expired or canceled, a webhook is sent to the client's URL. The developer area can also send a ping.

9.1 The request

POST <webhook URL>
Content-Type: application/json
User-Agent: pqc-connect/1
PQC-Connect-Event: signature_request.signed
PQC-Connect-Delivery: evt_3kP0qW8eR2tY6uI9oA1sD5fG
PQC-Connect-Signature: t=1793448291,v1=5d41402abc4b2a76b9719d911017c592...

{"id":"evt_3kP0qW8eR2tY6uI9oA1sD5fG","type":"signature_request.signed","mode":"live","created_at":"2026-10-30T12:04:52Z","data":{ ...the signature request object... }}

Event types: signature_request.signed, signature_request.declined, signature_request.expired, signature_request.canceled, ping, and for connections connection.connected, connection.revoked, connection.key_changed (each with the connection object) and connect_link.denied (with the link object).

9.2 The signature on a webhook

v1 = hex( HMAC-SHA256( key = webhook secret, data = t + "." + raw body ) )
  • The key is the whole secret string (whsec_...) as UTF-8 bytes.
  • t is the Unix time in seconds when this attempt was sent, as decimal digits.
  • The raw body is the exact bytes received, before any JSON parsing.

The receiver must:

  1. parse t and v1 from the header;
  2. recompute the HMAC and compare it with v1 in constant time;
  3. refuse the delivery if t is more than 300 seconds from its own clock, in either direction. Because t is inside the signed data, an attacker who captured a delivery cannot move it into the window by changing the header;
  4. keep the id of every event it has processed and ignore a repeat (retries carry the same id);
  5. for signature_request.signed, still check the signature itself (section 6). The webhook says something happened; the signature proves it.

9.3 Delivery and retries

  • Sent from a queue, on its own worker, so a slow endpoint cannot hold up PQC Imza's other work, and never from the request that caused the event. Test webhooks are sent only when no live webhook is waiting, because test traffic is free.
  • At most 10 jobs per client wait at once. Further events stay due, and the sweep (every minute) queues them as room opens. Nothing is dropped; they arrive later.
  • An attempt is claimed and counted before it is made, so jobs that pile up during an outage cannot spend the retries at once, two workers cannot send one attempt, and a worker killed mid-attempt still uses one up.
  • A 2xx answer within 10 seconds is success. Anything else, including a redirect, is a failure. Redirects are not followed.
  • Up to 8 attempts: at once, then after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours. Each attempt is signed afresh, with its own t. After the eighth failure the delivery is failed; the request is still readable with GET.
  • Every attempt is logged (time, HTTP status or error) and shown in the developer area under the request. How long an attempt took is not recorded, so the log cannot be used to scan ports.
  • The URL must be https://, on port 443 or 8443, with no user name or password. Its host is either a DNS name of at least two labels (letters, digits and hyphens, no trailing dot) or a public IP address in canonical form. A host whose last label is a number, or starts with 0x, counts as an address, so 0177.0.0.1, 2130706433 and 127.1 are refused.
  • Before each attempt the host is resolved and every address must be a public one (not loopback, private, link-local, carrier-grade NAT, documentation, multicast or reserved; for IPv6, inside 2000::/3 and outside 6to4, Teredo and documentation space). The connection is then forced to the address that was checked, and the address it actually used is compared afterwards; a difference is a failed attempt (address_mismatch). This keeps a webhook URL from being used to reach PQC Imza's own network.
  • The answer's body is discarded as it arrives.

9.4 Rotating the webhook secret

The developer area makes a new secret, shows it once, and signs every later attempt with it, retries of earlier events included. There is no overlap period: install the new secret first if you cannot afford to refuse a delivery, since refused deliveries are retried.


10. What the signer sees, and how they refuse

10.1 On the phone

A request is opened from the app's Sign tab or from a QR code on your page (10.4). After that both are the same.

The phone shows no file. The server sends no document for a v2 request, and the phone fetches no file. So no file the server chose reaches a renderer on this route.

The phone shows these, all parsed from the signed bytes, and no server text:

  • in a fixed block above Sign, which starts on screen: Signing as, which is Personal or On behalf of and the organisation's name, then From, your client's name, with the first-use mark. A very long name makes the block scroll within itself, and Sign stays off until all of it has been on screen;
  • in the list: Title, Content SHA-256 in full, Link, Client, Request, Key (the signer address in full, 0x and 40 hex characters, with Show full key for the key itself and its SHA-256), Expires.

Sign stays off until the block and the list have been scrolled to their ends. environment, client-nonce, created-at and organization-id are signed and not shown. No identity is shown or sent on a connect signature. The signer then confirms with a fingerprint, a face or the phone's screen lock.

Show full key shows the key's digest in groups of four characters. Show yours the same way, so the signer compares like with like.

An old phone cannot sign v2. App 1.7.1 is told 409 update_required when it opens the request, and the request stays pending. A new phone cannot sign a v1 request (409 reissue_required), so reissue it under v2.

10.2 Declining

From app 1.8.0 there is a Decline button on the phone. The request ends declined, and the webhook is sent. The web page's Decline stays as well. A decline is the server's word: it carries no signature.

The web page is the request's own page on the PQC Imza website, where the signer is signed in.

10.3 Expiry

A request not signed by expires_at becomes expired (checked every minute, and at once whenever the request is read or opened). It leaves the phone, and the webhook is sent. The server refuses a signature that arrives after expiry; the signature itself proves no time (section 8).

10.4 A QR code

From 1.8.0 the app reads a connect code with its own scanner. qr_content is built by the server and holds a request id, so a code on your page is a pointer: the phone opens the request it names for the account signed in on it, and refuses to sign if the request-id in the signed bytes is not the one it scanned. That compares one value the server chose. It does not show that the signer approved what your page asked for: that is your rebuild's job.

The same request is in the app's Sign tab as well, so a signer who does not scan loses nothing.

What the client gets. Every signature request object has qr_content, a string while the request is pending and null from the moment it ends. Before you draw it, check that it is exactly https://pqc-imza.com/connect/sign/ followed by the id you kept, and draw nothing if it is not: the code is on your page, and an ordinary camera opens whatever address it holds. Then put exactly that string in a QR code:

https://pqc-imza.com/connect/sign/sr_9QeV2mXo4b7TzL1wKc8yHn3d

Do not add a query string, a fragment or a tracking wrapper: the phone compares the whole string with the address compiled into it and refuses anything else, which is why qr_content always uses PQC Imza's fixed public address. Use error correction M or better, dark on light, with the standard quiet zone, at least 3 cm across on a screen.

What it contains. The address of the service and the request id, and nothing else: no secret, no title, no name, no key. The request id is not sensitive; it is inside the signed message. The code proves nothing and approves nothing, so it does no harm if someone else sees it. What protects the request is the signer's own signed-in account, then their key, then their fingerprint, face or phone screen lock.

How long it lives. As long as the request: until expires_at, or until it is signed, declined or cancelled. A code does not need refreshing, and has no lifetime of its own. Stop showing it when qr_content is null.

What happens when it is scanned.

Scanned by The request Answer to the phone
The named signer Pending A session, the same one the Sign tab opens. The phone shows what is in 10.1. Nothing is signed until they confirm.
The named signer, again Pending Another session. The first signature to arrive settles the request, and every other session closes.
The named signer Signed, declined, expired or cancelled 410, "code": "not_open", and status saying which. An expired request is noticed at the scan, without waiting for the minute's sweep.
The named signer Pending, but the client is disabled or consent was withdrawn 409, "code": "not_open".
The named signer, no proven key Pending 409, "code": "no_signing_key".
Any other account Any state, a test request, or no such request 403, "code": "wrong_key", the same bytes in every case. No session, no audit event, and nothing to the client.
Nobody signed in Any 401.
One account, more than 20 scans in a minute Any 429.

So a stranger holding a code learns nothing: not whether it is real, whether it has ended, or whose it is. A signature by the wrong key for the right account is still refused at submission (wrong_key), so a scan does not widen what any key may do.

What a code does not change. Section 7 reads the same for a scanned request as for a listed one. The client checks the signature exactly as in section 6. And the phone cannot know which page showed the code: any site could show it. The signer must read the phone, not the page. The phone shows the client's name and id from the request.


11. Limits, the audit log and errors

11.1 Rate limits

What Limit Over it
Any call, per API key 120 a minute 429 rate_limited
Creating requests, per client and mode, not counting a replay (3.4) 30 a minute 429 rate_limited
Creating connect links, per client and mode 60 a minute 429 rate_limited
Pending live requests, per client and signer 20 429 too_many_open_requests
Calls with an invalid key, per IP address 30 a minute 429 rate_limited
Developer-area changes, per account 20 a minute the page says so
A person's answers to a company (accept, deny, withdraw, a link, a decline), per account 20 a minute, counted on their own 429

A 429 carries Retry-After in seconds. Past 30 calls with an invalid key in a minute from one address, every further call from that address with a missing, malformed, unknown or revoked key gets 429 rate_limited instead of 401 until the minute ends. A valid key is never held back by its address. It is looked up first and the address count never applies to it, so neither a neighbour on a shared address (a NAT, a CI provider, a hosting platform) nor a revoked key left on one of your machines part way through a rotation (1.4) can block your valid calls. A 429 on a valid key is therefore one of the other limits above.

The per-key limit counts every call made with a valid key, including a call refused with 403 client_disabled (1.3) or 403 live_not_approved (1.2). Past 120 of them in a minute, that key gets 429 rate_limited too, until the minute ends.

11.2 The audit log

Every change is written to an audit log: who (an API key by its prefix, an account, an administrator or PQC Imza's own command line, the signer, or the system), what, which client and request, the IP address, and when. Never a key, a secret, or a signature.

Recorded: client.created, client.approved, client.disabled, client.enabled, key.created, key.revoked, webhook.url_changed, webhook.secret_rotated, webhook.ping, consent.granted, consent.revoked, request.created, request.idempotent_replay (at most once a minute for each request), request.opened (the phone opened it), request.signature_refused (with the reason), request.signed, request.declined, request.expired, request.canceled, request.simulated, link.created, link.allowed, link.denied, connection.connected, connection.revoked, connection.key_changed.

The developer area shows a client its own settings events and what the API already tells it about a request, with the actor as a role (signer, account, administrator), never an account id. It does not show consents, request.opened or request.signature_refused: when a signer looked, and what went wrong on their phone, are not a client's to know. A PQC Imza administrator sees everything.

11.3 Errors

{"code": "signer_unavailable", "error": "The signer cannot receive requests from this client."}

code is for programs and does not change; error is English text for a developer reading a log. Validation failures add "errors": {"field": ["..."]}.

An answer with no code did not come from pqc-connect's own checks: 404 for a path that does not exist (/connect-links under /api/connect/v2, for instance), 405, 413, 500, or 503 with Retry-After while a deploy runs. Treat it as no answer.

Status code
401 invalid_api_key
402 insufficient_credits: a live create, only once PQC Imza charges organisation credits for pqc-connect and the organisation's credits are used up. Not retried by the server; an administrator adds credits
403 client_disabled, live_not_approved
404 not_found, link_not_found, connection_not_found
409 idempotency_conflict, not_pending, link_closed, external_user_id_taken, account_already_connected, not_active
422 validation_failed, signer_unavailable, idempotency_key_missing, invalid_code
429 rate_limited, too_many_open_requests

12. Test mode

Test keys work the moment a client is registered, before any approval, and a test request never reaches a phone:

  • No account is looked up and no consent is needed; signer_email is stored and shown in results, nothing else.

  • Two addresses are reserved, so a client can test those answers:

    • signer-unavailable@example.com gets 422 signer_unavailable;
    • insufficient-credits@example.com gets 402 insufficient_credits, the answer a live create gets when the organisation's credits are used up (PQC Imza's docs/pqc-connect-v1.md, §9 and §11.1). It answers this way whatever the organisation's balance, and only with a test key.
  • The client decides the outcome:

    POST /api/connect/v2/signature-requests/{id}/simulate
    {"outcome": "signed" | "declined" | "expired"}
    

    signed produces a real post-quantum signature over the 5.1 message (with environment: test) by the client's sandbox key, a key pqc-connect generates for each client and keeps encrypted. It belongs to no account and no phone. The result and the webhook are exactly what live mode sends, so the client's verification code (section 6) can be tested end to end.

  • expired moves the request's expires_at to the moment you call it, so the object's expires_at then differs from what you sent. A live request's never changes. A client that compares every object it reads (section 3) will refuse a simulated expiry for that reason; expect it in your tests.

  • simulate with a live key, or on a live request, is 404 not_found.

  • A test connect link runs without a person. Its page is closed to everyone. Call POST /api/connect/v1/connect-links/{id}/simulate with {"outcome": "connected" | "denied" | "expired"}; connected returns the code a browser would have carried, so complete can be tested. The connection carries the sandbox key. simulate on a live link is 404 link_not_found.

  • organization_id follows the same rules in test mode (3.8), except that no membership is checked, because no account is looked up.

  • A test request may name a test connection by external_user_id, and any other id gets 422 signer_unavailable, so that answer can be tested too.

  • A test request has a qr_content, so that a page can be built and tested. No phone can use it: a scan is refused exactly as someone else's request is (10.4), and the camera's address ends at a 404.

  • Webhooks are sent for test events too, with "mode": "test".


Downloads

File What it is SHA-256
verify-connect-v2-signature.cjs The verifier script (6.2) dc79576d54518cbec68b47fac0a71982da33c59a984a69ef3d77b8df20ab1687
app-v2-signature.json The known answer (6.3). Its message's SHA-256 is 089fe67898f24c38836084f03ff3d6697636412f24759a5664a657739c1e5fdb 76cbfacc40322aec977c0e56b6328f744e8d2c23035bc6ebea16c9d114080fbb
SHA256SUMS A checksum file

The script runs on Node 20.19 or later, which loads ES modules through require, with @noble/post-quantum 0.6.1 and either @noble/hashes 2.2.0 or 1.3.2, whichever resolves where you put it. On an older Node, or with neither package, it refuses everything and says why. Copy it into your own code at a version you have read, and never fetch it at run time.

The checksums are served from the same site as the files, so they catch a damaged download, not a compromised site. To guard against that, compare the digests with PQC Imza through another channel before you rely on the files.